Merge remote-tracking branch 'origin/master' into feat/ripgrep-packaged-binary

This commit is contained in:
Huanqi Cao
2026-08-02 16:18:46 +08:00
470 changed files with 19821 additions and 1795 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/acp/acp/README.md
README.md: 1b188b994d17ce56e8d5df019ddef755338fcc88
README.zh.md: c1e7d045b55119b62ad44d81071188e1ed6110d5
README.md: 583025e94d72c1ab03d282f8f4eb101c4e6f4740
README.zh.md: 3a082b423c1e4ab7e236179a3f502cd450b4904c

View File

@@ -35,7 +35,7 @@ Committed-message output intentionally trades token-by-token latency for a clean
## Lifecycle
Client disconnect and Cordis disposal share one memoized teardown. The bridge first rejects new sessions and prompts, settles pending prompts, then disposes all owned agent handles in parallel and awaits their loop/session cleanup. An ACP-only plugin reload therefore leaves no orphan agent.
Client disconnect and Cordis disposal share one memoized teardown. The bridge first rejects new sessions and prompts, settles pending prompts, then drains continuable descendants only below this connection's exact owned Agents before disposing those handles in parallel and awaiting every result before reporting any failure. Other frontends sharing the Context retain their continuable forests and admission. An ACP-only plugin reload therefore leaves no orphan agent.
## Running

View File

@@ -35,7 +35,7 @@
## 生命周期
客户端断开连接与 Cordis 的 dispose资源释放共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,结算待处理提示词,然后并行对其拥有的全部 agent 句柄执行 dispose并等待它们的循环会话清理完成。因此单独重载 ACP 插件不会遗留孤儿 agent。
客户端断开与 Cordis 释放共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,结算待处理提示词,然后只 drain 此连接确切拥有的 Agent 之下的可继续后代,再并行释放这些 handle并等待全部结果结算后才报告失败。其他共享该上下文的前端会保留其可继续森林和准入。因此 ACP 插件重载不会遗留 agent。
## 运行

View File

@@ -43,6 +43,19 @@ export const name = 'acp'
/** The bridge creates and owns agents; every other concern is carried by the agent composition. */
export const inject = ['agents']
/**
* The single continuable-subagent teardown the bridge needs. Declared
* structurally so this package does not depend on the subagent seam for one
* shutdown hook; an absent service means nothing continuable was materialized.
*/
interface ContinuableDrain {
/**
* Close admission below exact host-owned parents, then dispose only their
* continuable descendants child-first.
*/
drainContinuableDescendants(parents: readonly Agent[]): Promise<void>
}
/** Preserve invalid-parameter detail in the SDK wire error message. */
function invalidParams(detail: string): RequestError {
return RequestError.invalidParams(undefined, detail)
@@ -326,10 +339,38 @@ export function apply(ctx: Context, config: AcpConfig): void {
closed = true
const records = [...sessions.values()]
sessions.clear()
quiescing = Promise.all(records.map(async (record) => {
// Stop the bridge's own work before any await: a descendant drain can block
// on persistence or scoped cleanup, and the top-level agents must not keep
// running model and tool calls for its whole duration.
for (const record of records) {
record.agent.cancel({ kind: 'user' })
settlePrompt(record, 'cancelled')
await record.dispose()
})).then(() => {})
}
quiescing = (async () => {
// Continuable subagents outlive the turn that started them, and their
// Activations own descendant teardown. Drain only these sessions' forests
// child-first BEFORE disposing the top-level agents, so no descendant is
// left holding a runtime its owner already released and another frontend
// sharing this Context remains live.
// Read the one teardown method structurally: the bridge needs no other
// part of the subagent seam, so it does not depend on that package.
const subagents = ctx.get('subagents') as ContinuableDrain | undefined
if (subagents !== undefined) {
try {
await subagents.drainContinuableDescendants(records.map(record => record.agent))
} catch (error: unknown) {
logger.warn(`acp: continuable subagent teardown failed: ${String(error)}`)
}
}
const disposals = await Promise.allSettled(records.map(record => record.dispose()))
const failures: unknown[] = []
for (const result of disposals) {
if (result.status === 'rejected') failures.push(result.reason as unknown)
}
if (failures.length > 0) {
throw new AggregateError(failures, `ACP agent teardown failed for ${failures.length} session(s)`)
}
})()
return quiescing
}

View File

@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import { makeBridgeHarness, type BridgeHarness } from './harness.ts'
@@ -25,6 +26,122 @@ describe('ACP connection ownership', () => {
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
})
it('drains continuable subagents before disposing its own sessions', async () => {
harness = await makeBridgeHarness()
const order: string[] = []
let drainedParents: readonly Agent[] = []
// A continuable Activation outlives the turn that started it, so the bridge
// must release that forest before the agents whose runtime it depends on.
harness.ctx.provide('subagents', {
drainContinuableDescendants: (parents: readonly Agent[]) => {
drainedParents = parents
order.push('drained')
return Promise.resolve()
},
} as never)
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = harness.ctx.agents.get(SessionId(sessionId))!
harness.ctx.on('agent/disposed', () => { order.push('agent disposed') })
await harness.acpFiber.dispose()
expect(order).toEqual(['drained', 'agent disposed'])
expect(drainedParents).toEqual([agent])
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
})
it('cancels its own prompt before awaiting the descendant drain', async () => {
harness = await makeBridgeHarness({ script: ['hang'] })
const order: string[] = []
const release = Promise.withResolvers<undefined>()
harness.ctx.provide('subagents', {
drainContinuableDescendants: async () => {
order.push('drain started')
await release.promise
order.push('drain finished')
},
} as never)
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = harness.ctx.agents.get(SessionId(sessionId))!
void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
await vi.waitFor(() => { expect(agent.status).toBe('running') })
harness.ctx.on('agent/cancel-requested', () => { order.push('parent cancelled') })
const disposal = harness.acpFiber.dispose()
// A drain can block on persistence, so the bridge's own turn must already be
// cancelled rather than running for its whole duration.
await vi.waitFor(() => { expect(order).toContain('drain started') })
expect(order).toEqual(['parent cancelled', 'drain started'])
release.resolve(undefined)
await disposal
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
})
it('reports a failed continuable drain and still disposes its sessions', async () => {
harness = await makeBridgeHarness()
const warnings: string[] = []
harness.ctx.logger.warn = (message: string) => { warnings.push(message) }
harness.ctx.provide('subagents', {
drainContinuableDescendants: () => Promise.reject(new Error('activation teardown failed')),
} as never)
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await harness.acpFiber.dispose()
// A stuck descendant must not strand the bridge's own teardown.
expect(warnings.some(warning => warning.includes('continuable subagent teardown failed'))).toBe(true)
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
})
it('awaits every owned session disposal before reporting one failure', async () => {
harness = await makeBridgeHarness()
const create = harness.ctx.agents.create.bind(harness.ctx.agents)
const releaseSecond = Promise.withResolvers<undefined>()
const warnings: string[] = []
let created = 0
let secondStarted = false
harness.ctx.logger.warn = (message: string) => { warnings.push(message) }
const createSpy = vi.spyOn(harness.ctx.agents, 'create').mockImplementation(async (options) => {
const handle = await create(options)
const originalDispose = handle.dispose.bind(handle)
if (created++ === 0) {
handle.dispose = async () => {
await originalDispose()
throw new Error('first session cleanup failed')
}
} else {
handle.dispose = async () => {
secondStarted = true
await releaseSecond.promise
await originalDispose()
}
}
return handle
})
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const first = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const second = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await harness.closeClientTransport()
await vi.waitFor(() => { expect(secondStarted).toBe(true) })
expect(warnings.some(warning => warning.includes('connection-close teardown failed'))).toBe(false)
releaseSecond.resolve(undefined)
await vi.waitFor(() => {
expect(warnings.some(warning => warning.includes('ACP agent teardown failed for 1 session(s)'))).toBe(true)
expect(harness!.ctx.agents.get(SessionId(first.sessionId))).toBeUndefined()
expect(harness!.ctx.agents.get(SessionId(second.sessionId))).toBeUndefined()
})
createSpy.mockRestore()
const disposed = harness
harness = undefined
await disposed.dispose().catch(() => undefined)
})
it('an ACP-only reload rejects new sessions before creating an orphan', async () => {
harness = await makeBridgeHarness()
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })

View File

@@ -16,6 +16,7 @@ export type {
GoalsApi, GoalRef,
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
CredentialsApi, CredentialView, ConfigurableProviderView, LlmApi,
SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
} from '@deepseek-ai/dsh-host-apiproxy/api'
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
export type {

View File

@@ -1910,6 +1910,19 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
return ok(request, { accepted: true as const })
},
},
subagents: {
list: request => ok(request, { entries: [], parentAvailable: true }),
history: (request) => {
const log = logs.get(request.payload.childSessionId) ?? []
return Promise.resolve(ok(
request,
pageOf(log, request.payload.beforeSeq, request.payload.maxMessages ?? 50),
))
},
prompt: request => Promise.resolve(ok(request, {
messageId: `fixture-message-${request.payload.childSessionId}` as never,
})),
},
host: {
describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }),
// Deterministic native pick: the keyless lanes drive the full
@@ -2420,6 +2433,9 @@ export class FixtureApiClient extends AbstractApiClient {
case 'session.prompt': return this.api.sessions.prompt(request)
case 'session.updateQueue': return this.api.sessions.updateQueue(request)
case 'session.cancel': return this.api.sessions.cancel(request)
case 'subagent.list': return this.api.subagents.list(request)
case 'subagent.history': return this.api.subagents.history(request)
case 'subagent.prompt': return this.api.subagents.prompt(request, signal)
case 'host.describe': return this.api.host.describe(request)
case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal)
case 'host.listDirectory': return this.api.host.listDirectory(request, new AbortController().signal)

View File

@@ -18,6 +18,7 @@ export type {
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
InboxItemId, ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,

View File

@@ -113,6 +113,20 @@ export class FakeApiClient implements IApiClient {
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
}
readonly subagents: IApiClient['subagents'] = {
list: (payload: unknown) => this.record('subagent.list', payload, Promise.resolve(ok({
entries: [],
parentAvailable: true,
}))),
history: (payload: unknown) => this.record('subagent.history', payload, Promise.resolve(ok({
events: [],
hasMore: false,
}))),
prompt: (payload: unknown) => this.record('subagent.prompt', payload, Promise.resolve(ok({
messageId: 'fake-message' as never,
}))),
}
readonly host: IApiClient['host'] = {
describe: payload => this.record('host.describe', payload, this.onDescribe(payload)),
pickDirectory: payload => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)),

View File

@@ -16,7 +16,7 @@ const OPTIONS = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }]
/** Empty global standard-kit hooks (the row reads neither). */
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready' })
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
return bindSnapshotSelector(store)
}
function emptyWorkspaces() {

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: 0ae71ff17b13be67c16786ff69a0e1626437913a
README.zh.md: 52a443d9df753ba01650b6cbf189c39633f6a461
README.md: f956be22384a42e9ed30e8aa5f25fe8173cc9f9c
README.zh.md: 49449c51d89d957b5bd39798c9167607f72a5c3a

View File

@@ -54,6 +54,10 @@ The Session object validates plugin-owned, provider-routed `llm/retry` payloads
Each resident `Session` owns a `modelSelection` snapshot containing the current provider/model target, provider-grouped directory, provider-local failures, and the `idle`/`loading`/`ready`/`selecting`/`error` state. History establishes or refreshes the current target, opening a selector refreshes the directory, and selection failures preserve the last target and usable groups. Directory and selection operations share a monotonically increasing generation so an older response cannot overwrite a newer selection. A reconnect rebuild restores the target reported by the Host without replacing unchanged selection substructure.
## Addressed subagent conversations
`SessionListState.subagentsByParent` carries direct durable catalogs and `currentAddress` records the catalog-derived `{parentSessionId, childSessionId}` for the selected child. Only that recorded address selects subagent transport: lineage alone remains insufficient because ordinary forks also have `parentId`. An addressed Session loads and reconnects through `subagent.history`, sends through `subagent.prompt`, never calls ordinary cancel, and persists its address with the selected session across refresh and repeated ordinary selection of that same child. The list also projects the header's coarse `origin: 'subagent'` classification for navigation filtering; the recorded address, not `origin`, remains transport authority. Catalog reads are single-flight; the Host baseline and `host/session-status` both derive activity from child Agent driver status, and status frames received during a read are replayed over its response. An origin-classified `host/session-added` immediately marks any loaded direct parent row `hasChildren: true` and causes one debounced refetch when that parent is selected or its catalog is open. Parent availability propagates into `ConversationSnapshot.subagent` so presentation can replace the composer with a read-only explanation without activating the parent.
## Model Experience
None, as the session object layer selects the provider/model route used by a later Host request but adds no model-visible content.

View File

@@ -54,6 +54,10 @@ Session 对象会在事件 wire 边界依据生产方的完整字段契约,验
每个常驻 `Session` 都拥有一个 `modelSelection` 快照,其中包含当前提供方/模型目标、按提供方分组的目录、逐提供方失败记录,以及 `idle``loading``ready``selecting``error` 状态。历史记录会建立或刷新当前目标,打开选择器会刷新目录;选择失败会保留上一个目标和可用分组。目录与选择操作共用单调递增的代次,因此较旧响应无法覆盖较新的选择。重连重建会恢复 Host 报告的目标,同时不替换未变化的选择子结构。
## 已寻址的 subagent 对话
`SessionListState.subagentsByParent` 携带直接持久化目录,`currentAddress` 则记录所选 child 从目录得到的 `{parentSessionId, childSessionId}`。只有这份已记录地址能选择 subagent 传输;单凭谱系仍然不足,因为普通 fork 同样具有 `parentId`。已寻址的 Session 通过 `subagent.history` 加载和重连,通过 `subagent.prompt` 发送,绝不调用普通取消,并在刷新期间及通过普通选择路径重复选择同一 child 时,把地址与所选会话一同持久化。列表还会投影 header 的粗粒度 `origin: 'subagent'` 分类供导航过滤;传输的权威依据仍是已记录地址,而不是 `origin`。目录读取为 single-flightHost 基线与 `host/session-status` 都根据 child Agent driver 状态推导活动状态,读取期间收到的状态帧会在该读取的响应之上回放。按 origin 分类的 `host/session-added` 会立即把任何已加载的直接 parent 行标记为 `hasChildren: true`,并在该 parent 被选中或其目录打开时触发一次去抖动的重拉。parent 可用性会传播到 `ConversationSnapshot.subagent`,使呈现层可以把编辑器替换为只读说明,而不激活 parent。
## 模型体验
无,因为会话对象层会选择后续 Host 请求使用的提供方/模型路由,但不添加任何模型可见内容。

View File

@@ -8,7 +8,9 @@
* explicit act of widening what features may do to the sessions domain.
*/
import type { Context } from 'cordis'
import type { RpcResult, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type {
RpcResult, SessionId, SubagentAddress,
} from '@deepseek-ai/dsh-client-connection/client'
import type { HostObservable, SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionSearchResultItem } from '../sessions/manager.ts'
import type {
@@ -34,6 +36,29 @@ export interface ISessions {
* @param id - session id (must exist in the list; unknown ids fail loud).
*/
open(id: SessionId): void
/**
* Open a healthy catalog child through its exact direct-parent address.
* @param address - catalog-derived parent and child ids.
*/
openSubagent(address: SubagentAddress): void
/**
* Resolve an already discovered direct-parent address without opening it.
* @param id - possible addressed child id.
* @returns the retained address, when present.
*/
subagentAddress(id: SessionId): SubagentAddress | undefined
/**
* Mark whether a catalog menu is consuming live membership updates.
* @param parentSessionId - catalog owner.
* @param open - current menu state.
*/
setSubagentCatalogOpen(parentSessionId: SessionId, open: boolean): void
/**
* Refresh one direct-child catalog.
* @param parentSessionId - catalog owner.
* @returns completion of the current or newly started refresh.
*/
refreshSubagents(parentSessionId: SessionId): Promise<void>
/** Clear the current selection into the no-session view state. */
clear(): void
/**

View File

@@ -31,7 +31,8 @@ export type { IWorkspaces } from './contract/workspaces.ts'
export type {
SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary,
} from './sessions/service.ts'
export type { SessionListPhase, SessionSearchResultItem } from './sessions/manager.ts'
export type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './sessions/manager.ts'
export type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client'
export type { WorkspaceListPhase } from './workspaces/manager.ts'
export type { WorkspaceListState } from './workspaces/service.ts'
export type {

View File

@@ -8,7 +8,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
import type { TodoItem } from '@deepseek-ai/dsh-session/types'
import type {
InboxItemId, RpcError, SessionId, ToolCallView, ToolResultView,
InboxItemId, RpcError, SessionId, SubagentAddress, ToolCallView, ToolResultView,
} from '@deepseek-ai/dsh-client-connection/client'
import type { PendingInteraction } from './pending.ts'
export type { TodoItem }
@@ -335,6 +335,11 @@ export interface ConversationSnapshot {
/** Authoritative transient inbox snapshot, replaced after every host-side change. */
queue: readonly QueuedMessage[]
running: boolean
/**
* Catalog-discovered continuation address. Its parent availability controls
* human input; null means ordinary session transport.
*/
subagent: { address: SubagentAddress; parentAvailable: boolean } | null
/** Input-area shape (see {@link ComposerPhase}); derived here, switched on by consumers. */
composerPhase: ComposerPhase
/** Set after host/session-removed; the UI grays out and disables input. */

View File

@@ -18,6 +18,8 @@ export interface SessionListEntry {
/** Empty-log bit mirrored from the summary; lists hide blank sessions (filtering stays with the consumer). */
blank: boolean
parentSessionId?: SessionId
/** Coarse durable origin for navigation filtering; not a continuation capability. */
origin?: 'subagent'
cwd?: string
/** An approval question is pending on this session (mux-frame derived; the sidebar's amber dot). */
waitingApproval: boolean

View File

@@ -4,7 +4,7 @@
import type {
IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId,
SessionSummary, WorkspaceId,
SessionSummary, SubagentAddress, SubagentCatalog, WorkspaceId,
} from '@deepseek-ai/dsh-client-connection/client'
// Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error.
@@ -45,6 +45,20 @@ export interface SessionListSnapshot {
/** Arrival lifecycle (see {@link SessionListPhase}); `state` stays the pull-activity axis. */
phase: SessionListPhase
error: RpcError | null
subagentsByParent: Readonly<Record<SessionId, SubagentCatalogSnapshot>>
currentAddress: SubagentAddress | undefined
}
/** One parent-addressed durable catalog projected through the sessions snapshot. */
export interface SubagentCatalogSnapshot extends SubagentCatalog {
state: 'loading' | 'ready' | 'error'
error: RpcError | null
}
interface CatalogInflight {
readonly promise: Promise<void>
readonly expandableRows: Set<SessionId>
readonly activityRows: Map<SessionId, 'running' | 'inactive'>
}
type SessionListMutation =
@@ -84,6 +98,11 @@ export class SessionManager {
private listInflight: Promise<void> | null = null
/** Mutations arriving after a list request starts are replayed over its response. */
private listMutations: SessionListMutation[] | null = null
private readonly addresses = new Map<SessionId, SubagentAddress>()
private readonly catalogs = new Map<SessionId, SubagentCatalogSnapshot>()
private readonly catalogInflight = new Map<SessionId, CatalogInflight>()
private readonly openCatalogs = new Set<SessionId>()
private readonly catalogDebounce = new Map<SessionId, ReturnType<typeof setTimeout>>()
private selected: SessionId | undefined
@@ -104,22 +123,50 @@ export class SessionManager {
constructor(
private readonly api: IApiClient,
restoredSelection?: SessionId,
restoredAddress?: SubagentAddress,
) {
this.selected = restoredSelection
if (restoredAddress !== undefined) this.addresses.set(restoredAddress.childSessionId, restoredAddress)
this.listSnapshotCache = this.buildListSnapshot()
}
// ---- Selection ----
/**
* Select a listed Session.
* @param sessionId - listed Session id.
* Select a listed Session or a retained catalog-addressed child.
* @param sessionId - listed or catalog-addressed Session id.
*/
select(sessionId: SessionId): void {
if (!this.summaries.some(summary => summary.sessionId === sessionId)) {
const address = this.navigationAddress(sessionId)
if (!this.summaries.some(summary => summary.sessionId === sessionId) && address === undefined) {
throw new Error(`sessions.select: unknown session ${sessionId}`)
}
if (address !== undefined) this.addresses.set(sessionId, address)
this.sessions.get(sessionId)?.configureSubagent(
address,
address === undefined
? false
: this.catalogs.get(address.parentSessionId)?.parentAvailable ?? false,
)
this.selected = sessionId
void this.refreshSubagents(sessionId)
this.notifier.notifyNow()
}
/**
* Select a healthy child through its durable direct-parent address.
* @param address - catalog-derived parent and child ids.
*/
selectSubagent(address: SubagentAddress): void {
const catalog = this.catalogs.get(address.parentSessionId)
const entry = catalog?.entries.find(candidate => candidate.id === address.childSessionId)
if (entry === undefined || entry.kind !== 'child' || entry.mode !== address.mode) {
throw new Error(`sessions.selectSubagent: ${address.childSessionId} is not a healthy catalog child`)
}
this.addresses.set(address.childSessionId, address)
this.sessions.get(address.childSessionId)?.configureSubagent(address, catalog?.parentAvailable ?? false)
this.selected = address.childSessionId
void this.refreshSubagents(address.childSessionId)
this.notifier.notifyNow()
}
@@ -129,6 +176,32 @@ export class SessionManager {
this.notifier.notifyNow()
}
/**
* Return the durable catalog address retained for one child.
* @param sessionId - possible addressed child id.
* @returns The direct-parent address, when navigation discovered one.
*/
subagentAddress(sessionId: SessionId): SubagentAddress | undefined {
return this.addresses.get(sessionId)
}
/**
* Resolve an address for breadcrumb navigation without retaining transport authority.
* @param sessionId - possible child id in an already-loaded catalog.
* @returns A retained or catalog-derived direct-parent address.
*/
navigationAddress(sessionId: SessionId): SubagentAddress | undefined {
const retained = this.addresses.get(sessionId)
if (retained !== undefined) return retained
for (const [parentSessionId, catalog] of this.catalogs) {
const child = catalog.entries.find(entry => entry.kind === 'child' && entry.id === sessionId)
if (child?.kind === 'child') {
return { parentSessionId, childSessionId: sessionId, mode: child.mode }
}
}
return undefined
}
// ---- Instance management ----
/**
@@ -168,13 +241,23 @@ export class SessionManager {
if (summary !== undefined) {
session.handleBlank(summary.blank)
session.handleRunning(summary.running)
} else {
const address = this.addresses.get(sessionId)
const child = address === undefined ? undefined : this.catalogs.get(address.parentSessionId)?.entries
.find(entry => entry.kind === 'child' && entry.id === sessionId)
if (child?.kind === 'child') session.handleRunning(child.activity === 'running')
}
}
return session
}
private createSession(sessionId: SessionId): Session {
const address = this.addresses.get(sessionId)
return new Session(sessionId, this.api, {
...(address === undefined ? {} : {
address,
parentAvailable: this.catalogs.get(address.parentSessionId)?.parentAvailable ?? false,
}),
// The sender's local first-send flip mirrors into the list row so the
// session surfaces (lists filter on blank) before any host frame lands.
onEngaged: (engaged) => {
@@ -197,6 +280,85 @@ export class SessionManager {
return store
}
/**
* Refresh one direct-child catalog, reusing its in-flight request.
* @param parentSessionId - catalog owner.
*/
refreshSubagents(parentSessionId: SessionId): Promise<void> {
const existing = this.catalogInflight.get(parentSessionId)
if (existing !== undefined) return existing.promise
const previous = this.catalogs.get(parentSessionId)
const expandableRows = new Set<SessionId>()
const activityRows = new Map<SessionId, 'running' | 'inactive'>()
this.catalogs.set(parentSessionId, {
entries: previous?.entries ?? [],
parentAvailable: previous?.parentAvailable ?? false,
state: 'loading',
error: null,
})
this.notifier.markDirty()
const operation = (async () => {
try {
const { result } = await this.api.subagents.list({ parentSessionId })
if (result.ok) {
this.catalogs.set(parentSessionId, {
...result.value,
entries: this.withCatalogMutations(result.value.entries, expandableRows, activityRows),
state: 'ready',
error: null,
})
for (const [childId, address] of this.addresses) {
if (address.parentSessionId !== parentSessionId) continue
this.sessions.get(childId)?.handleSubagentParentAvailable(result.value.parentAvailable)
}
} else {
this.catalogs.set(parentSessionId, {
entries: this.withCatalogMutations(
previous?.entries ?? [], expandableRows, activityRows,
),
parentAvailable: previous?.parentAvailable ?? false,
state: 'error',
error: result.error,
})
}
} catch (error: unknown) {
const folded = transportError<never>(error)
this.catalogs.set(parentSessionId, {
entries: this.withCatalogMutations(
previous?.entries ?? [], expandableRows, activityRows,
),
parentAvailable: previous?.parentAvailable ?? false,
state: 'error',
error: folded.ok ? null : folded.error,
})
} finally {
this.catalogInflight.delete(parentSessionId)
this.notifier.markDirty()
}
})()
this.catalogInflight.set(parentSessionId, { promise: operation, expandableRows, activityRows })
return operation
}
/**
* Mark whether a catalog menu is consuming live membership updates.
* @param parentSessionId - catalog owner.
* @param open - current menu state.
*/
setSubagentCatalogOpen(parentSessionId: SessionId, open: boolean): void {
if (open) {
this.openCatalogs.add(parentSessionId)
void this.refreshSubagents(parentSessionId)
} else {
this.openCatalogs.delete(parentSessionId)
const timer = this.catalogDebounce.get(parentSessionId)
if (timer !== undefined) {
clearTimeout(timer)
this.catalogDebounce.delete(parentSessionId)
}
}
}
// ---- List surface ----
/** Full refresh via session.list (single-flight: an in-flight call is reused). */
@@ -481,22 +643,42 @@ export class SessionManager {
this.mergeSummary({
sessionId: frame.sessionId, updatedAt: Date.now(), running: false, blank: frame.blank,
...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}),
...(frame.origin !== undefined ? { origin: frame.origin } : {}),
...(frame.cwd !== undefined ? { cwd: frame.cwd } : {}),
})
this.sessions.get(frame.sessionId)?.handleBlank(frame.blank)
if (frame.origin === 'subagent' && frame.parentSessionId !== undefined) {
this.markCatalogParentExpandable(frame.parentSessionId)
}
if (frame.parentSessionId !== undefined
&& (this.selected === frame.parentSessionId || this.openCatalogs.has(frame.parentSessionId))) {
this.scheduleCatalogRefresh(frame.parentSessionId)
}
return
}
case 'host/session-removed': {
this.recordMutation({ kind: 'remove', sessionId: frame.sessionId })
this.sessions.get(frame.sessionId)?.handleRemoved() // instance survives (resident-instance rule), only flagged in the snapshot
const summary = this.summaries.find(candidate => candidate.sessionId === frame.sessionId)
const durableSubagent = summary?.origin === 'subagent' || this.addresses.has(frame.sessionId)
this.recordMutation(durableSubagent
? { kind: 'status', sessionId: frame.sessionId, running: false }
: { kind: 'remove', sessionId: frame.sessionId })
this.updateCatalogActivity(frame.sessionId, false)
if (durableSubagent) {
// An Activation detaching is not durable child deletion:
// keep its lineage and conversation while returning it to idle.
this.sessions.get(frame.sessionId)?.handleRunning(false)
} else {
this.sessions.get(frame.sessionId)?.handleRemoved()
}
this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
this.waitingApprovals.delete(frame.sessionId) // a removed session cannot wait on anyone
this.projectionStores.delete(frame.sessionId) // removed sessions drop their projection rows with the instance
if (!durableSubagent) this.projectionStores.delete(frame.sessionId)
return
}
case 'host/session-status': {
this.recordMutation({ kind: 'status', sessionId: frame.sessionId, running: frame.running })
this.sessions.get(frame.sessionId)?.handleRunning(frame.running)
this.updateCatalogActivity(frame.sessionId, frame.running)
return
}
case 'host/agent-error': {
@@ -535,9 +717,83 @@ export class SessionManager {
/** After each connection generation: refresh the session baseline and rebuild opened windows. */
handleConnected(): void {
void this.refreshList()
const selectedAddress = this.selected === undefined ? undefined : this.addresses.get(this.selected)
if (selectedAddress !== undefined) void this.refreshSubagents(selectedAddress.parentSessionId)
if (this.selected !== undefined) void this.refreshSubagents(this.selected)
for (const parentSessionId of this.openCatalogs) void this.refreshSubagents(parentSessionId)
for (const session of this.sessions.values()) void session.resync()
}
/** Debounce membership refetches while one parent catalog is open. */
private scheduleCatalogRefresh(parentSessionId: SessionId): void {
if (this.catalogDebounce.has(parentSessionId)) return
const timer = setTimeout(() => {
this.catalogDebounce.delete(parentSessionId)
void this.refreshSubagents(parentSessionId)
}, 50)
this.catalogDebounce.set(parentSessionId, timer)
}
/** Apply one Agent-driver transition to loaded and in-flight catalogs. */
private updateCatalogActivity(childSessionId: SessionId, running: boolean): void {
const activity = running ? 'running' as const : 'inactive' as const
for (const inflight of this.catalogInflight.values()) {
inflight.activityRows.set(childSessionId, activity)
}
let changed = false
for (const [parentSessionId, catalog] of this.catalogs) {
if (!catalog.entries.some(entry =>
entry.kind === 'child' && entry.id === childSessionId && entry.activity !== activity)) continue
const entries = catalog.entries.map((entry) => {
if (entry.kind !== 'child' || entry.id !== childSessionId) return entry
return { ...entry, activity }
})
changed = true
this.catalogs.set(parentSessionId, { ...catalog, entries })
}
if (changed) this.notifier.markDirty()
}
/** Preserve and project a positive expandability hint after one direct subagent publishes. */
private markCatalogParentExpandable(parentSessionId: SessionId): void {
this.applyCatalogParentExpandable(parentSessionId)
for (const inflight of this.catalogInflight.values()) inflight.expandableRows.add(parentSessionId)
}
/** Apply one positive expandability hint to every loaded catalog containing that unique row id. */
private applyCatalogParentExpandable(parentSessionId: SessionId): void {
let changed = false
for (const [catalogParentId, catalog] of this.catalogs) {
if (!catalog.entries.some(entry =>
entry.kind === 'child' && entry.id === parentSessionId && !entry.hasChildren)) continue
const entries = catalog.entries.map((entry) => {
if (entry.kind !== 'child' || entry.id !== parentSessionId || entry.hasChildren) return entry
return { ...entry, hasChildren: true }
})
changed = true
this.catalogs.set(catalogParentId, { ...catalog, entries })
}
if (changed) this.notifier.markDirty()
}
/** Fold request-local row mutations into one catalog result before publication. */
private withCatalogMutations(
entries: SubagentCatalog['entries'],
expandableRows: ReadonlySet<SessionId>,
activityRows: ReadonlyMap<SessionId, 'running' | 'inactive'>,
): SubagentCatalog['entries'] {
return entries.map((entry) => {
if (entry.kind !== 'child') return entry
const activity = activityRows.get(entry.id)
if (!expandableRows.has(entry.id) && activity === undefined) return entry
return {
...entry,
...expandableRows.has(entry.id) ? { hasChildren: true } : {},
...activity === undefined ? {} : { activity },
}
})
}
private buildListSnapshot(): SessionListSnapshot {
const merged: TitledSessionSummary[] = this.summaries.map((summary) => {
// List rows read the generic 'title' projection key (host-computed unit
@@ -554,7 +810,7 @@ export class SessionManager {
prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running
&& prev.blank === entry.blank
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd
&& prev.title === entry.title && prev.depth === entry.depth
&& prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth
&& prev.waitingApproval === entry.waitingApproval
) return prev
this.entryCache.set(entry.sessionId, entry)
@@ -566,7 +822,8 @@ export class SessionManager {
const sameOrder = items.length === this.itemsCache.length && items.every((e, i) => e === this.itemsCache[i])
if (!sameOrder) this.itemsCache = items
const selected = this.selected
const current = selected !== undefined && items.some(item => item.sessionId === selected)
const current = selected !== undefined
&& (items.some(item => item.sessionId === selected) || this.addresses.has(selected))
? selected
: undefined
return {
@@ -575,6 +832,8 @@ export class SessionManager {
state: this.listState,
phase: this.listPhase,
error: this.listError,
subagentsByParent: Object.fromEntries(this.catalogs),
currentAddress: current === undefined ? undefined : this.addresses.get(current),
}
}
}
@@ -593,9 +852,11 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi
...(existing.cwd === undefined && mutation.summary.cwd !== undefined ? { cwd: mutation.summary.cwd } : {}),
...(existing.parentSessionId === undefined && mutation.summary.parentSessionId !== undefined
? { parentSessionId: mutation.summary.parentSessionId } : {}),
...(existing.origin === undefined && mutation.summary.origin !== undefined
? { origin: mutation.summary.origin } : {}),
}
if (filled.cwd === existing.cwd && filled.parentSessionId === existing.parentSessionId
&& filled.blank === existing.blank) return [...summaries]
&& filled.origin === existing.origin && filled.blank === existing.blank) return [...summaries]
return summaries.map(summary => summary.sessionId === mutation.summary.sessionId ? filled : summary)
}
case 'remove':

View File

@@ -4,7 +4,7 @@
* 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.
* id), stable SessionBinding cache, breadcrumb-route projection.
*
* Scope lifecycle is stage-driven: a scope is minted lazily on first
* resolution (pure — resolution has no side effects and is render-safe);
@@ -17,7 +17,7 @@
*/
import type { Context, Fiber } from 'cordis'
import type {
IApiClient, RpcError, RpcResult, SessionId, WorkspaceId,
IApiClient, RpcError, RpcResult, SessionId, SubagentAddress, WorkspaceId,
} from '@deepseek-ai/dsh-client-connection/client'
// Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error.
@@ -31,7 +31,7 @@ import type { SessionFace } from '../contract/session.ts'
import type { ISessions } from '../contract/sessions.ts'
import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts'
import { SessionManager } from './manager.ts'
import type { SessionListPhase, SessionSearchResultItem } from './manager.ts'
import type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './manager.ts'
import { SessionProvideChannel } from './provide.ts'
import type { Session } from './session.ts'
@@ -44,6 +44,8 @@ export interface SessionSummary {
displayTitle: string
cwd?: string
parentId?: SessionId
/** Coarse durable origin for navigation filtering; not a continuation capability. */
origin?: 'subagent'
running: boolean
/** An approval question is pending on this session (sidebar amber-dot state). */
waitingApproval: boolean
@@ -63,11 +65,23 @@ export interface SessionSummary {
* sidebar highlighting and SessionProvider share one fact source).
*/
export interface SessionListState {
/** Host-list order; addressed breadcrumb-only rows are excluded. */
ids: SessionId[]
/** Host rows plus the current addressed subagent route used by navigation. */
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
/** Direct durable catalogs keyed by their selected parent address. */
subagentsByParent: Readonly<Record<SessionId, SubagentCatalogSnapshot>>
/** Current session's catalog-derived address, absent on ordinary navigation. */
currentAddress: SubagentAddress | undefined
}
/** Persisted navigation cell: address survives refresh for correct history routing. */
interface SessionSelection {
sessionId?: SessionId
subagentAddress?: SubagentAddress
}
/** Structured session-create failure. */
@@ -192,7 +206,7 @@ export interface SessionProvideDescriptor {
resolve(binding: SessionBinding): SessionProvideContribution
}
/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, ancestry. */
/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, and breadcrumb routes. */
export class SessionsService implements ISessions {
/**
* The wire schema's own result bound, re-exposed for presentation plugins as
@@ -221,7 +235,7 @@ export class SessionsService implements ISessions {
* selection survives transient list states (reconnect re-pull) and
* resurfaces when its session returns.
*/
private readonly selection: SnapshotStore<{ sessionId?: SessionId }>
private readonly selection: SnapshotStore<SessionSelection>
private readonly scopes = new Map<SessionId, ScopeRecord>()
/** The provide channel (roster, materialization rules, current projection) — shared with the test runtime's double. */
@@ -244,12 +258,14 @@ export class SessionsService implements ISessions {
private readonly rootCtx: Context,
api: IApiClient,
) {
this.selection = createSnapshotStore<{ sessionId?: SessionId }>(
this.selection = createSnapshotStore<SessionSelection>(
{},
{ persist: { name: 'dsh.sessions.current' } })
this.manager = new SessionManager(api, this.selection.getSnapshot().sessionId)
const restored = this.selection.getSnapshot()
this.manager = new SessionManager(api, restored.sessionId, restored.subagentAddress)
this.list = createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'pending',
subagentsByParent: {}, currentAddress: undefined,
})
// The manager owns wire truth; the store is its projection. Manager
// notifications are already microtask-batched.
@@ -295,14 +311,48 @@ export class SessionsService implements ISessions {
}
/**
* Select a session as current. Unknown ids fail loud instead of navigating
* nowhere.
* @param id - session id (must exist in the list store).
* Select a listed or retained catalog-addressed session as current.
* @param id - listed or addressed session id.
*/
open(id: SessionId): void {
this.manager.select(id)
}
/**
* Open a healthy catalog child through its direct-parent address.
* @param address - catalog-derived parent and child ids.
*/
openSubagent(address: SubagentAddress): void {
this.manager.selectSubagent(address)
}
/**
* Resolve an already discovered direct-parent address without opening it.
* Feature plugins use this to avoid Agent-bound RPCs in persisted child views.
* @param id - possible addressed child id.
* @returns The retained address, when present.
*/
subagentAddress(id: SessionId): SubagentAddress | undefined {
return this.manager.subagentAddress(id)
}
/**
* Inform the runtime whether a catalog menu is consuming membership updates.
* @param parentSessionId - selected parent.
* @param open - menu state.
*/
setSubagentCatalogOpen(parentSessionId: SessionId, open: boolean): void {
this.manager.setSubagentCatalogOpen(parentSessionId, open)
}
/**
* Refresh one direct-child catalog.
* @param parentSessionId - catalog owner.
*/
refreshSubagents(parentSessionId: SessionId): Promise<void> {
return this.manager.refreshSubagents(parentSessionId)
}
/**
* Clear the current selection so the layout shows the no-session empty
* state (new-session affordance and the workspace preselection flow).
@@ -509,33 +559,15 @@ export class SessionsService implements ISessions {
* cannot miss; kept so a future current writer cannot crash the notify. */
if (record !== undefined) {
void record.session.open()
void this.manager.refreshSubagents(current)
}
}
/**
* 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.
* prune share one predicate (decision 12): listed on the host or selected
* through a retained subagent address. Breadcrumb-only ancestors remain
* summary data and do not keep scopes alive.
*/
private resolve(id: SessionId): ScopeRecord | undefined {
const existing = this.scopes.get(id)
@@ -559,14 +591,17 @@ export class SessionsService implements ISessions {
return record
}
/** The one aliveness predicate shared by scope mint and prune: host-listed. */
/** The one aliveness predicate shared by scope mint and prune: host-listed or currently addressed. */
private eligible(id: SessionId): boolean {
return this.list.getSnapshot().byId[id] !== undefined
const { ids, current } = this.list.getSnapshot()
return current === id || ids.includes(id)
}
/** 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 {
items, current, phase, subagentsByParent, currentAddress,
} = this.manager.getListSnapshot()
const ids: SessionId[] = []
const byId: Record<SessionId, SessionSummary> = {}
for (const entry of items) {
@@ -581,6 +616,37 @@ export class SessionsService implements ISessions {
...(entry.title !== undefined ? { title: entry.title } : {}),
...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}),
...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
...(entry.origin !== undefined ? { origin: entry.origin } : {}),
}
}
if (current !== undefined && currentAddress !== undefined) {
const seen = new Set<SessionId>()
let address: SubagentAddress | undefined = currentAddress
while (address !== undefined && !seen.has(address.childSessionId)) {
const childId = address.childSessionId
seen.add(childId)
const child = subagentsByParent[address.parentSessionId]?.entries
.find(entry => entry.kind === 'child' && entry.id === childId)
if (child?.kind !== 'child') break
const displayTitle = child.label ?? childId
const summary = byId[childId]
if (summary === undefined) {
byId[childId] = {
id: childId,
displayTitle,
parentId: address.parentSessionId,
origin: 'subagent',
running: child.activity === 'running',
waitingApproval: false,
blank: false,
updatedAt: 0,
}
} else if (summary.displayTitle !== displayTitle) {
byId[childId] = { ...summary, displayTitle }
}
const parent = byId[address.parentSessionId]
if (parent !== undefined && parent.origin !== 'subagent') break
address = this.manager.navigationAddress(address.parentSessionId)
}
}
const persisted = this.selection.getSnapshot().sessionId
@@ -588,16 +654,22 @@ export class SessionsService implements ISessions {
// 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 })
} else if (byId[current] !== undefined
&& (persisted !== current
|| this.selection.getSnapshot().subagentAddress?.childSessionId !== currentAddress?.childSessionId
|| this.selection.getSnapshot().subagentAddress?.parentSessionId !== currentAddress?.parentSessionId
|| this.selection.getSnapshot().subagentAddress?.mode !== currentAddress?.mode)) {
this.selection.set({
sessionId: current,
...(currentAddress === undefined ? {} : { subagentAddress: currentAddress }),
})
}
this.list.set({ ids, byId, current, phase })
this.pruneScopes(byId)
this.list.set({ ids, byId, current, phase, subagentsByParent, currentAddress })
this.pruneScopes()
}
/** 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
private pruneScopes(): void {
for (const [id, record] of this.scopes) {
if (this.eligible(id)) continue
if (id === this.watched) {

View File

@@ -6,7 +6,7 @@ import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
HistoryEntry, IApiClient, InboxItemId, MuxFrame, QueueAction, RpcError,
RpcId, RpcResult, SessionId, ToolEventView,
RpcId, RpcResponse, RpcResult, SessionId, SubagentAddress, ToolEventView,
} from '@deepseek-ai/dsh-client-connection/client'
// Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error.
@@ -34,6 +34,10 @@ const MAX_RETRY_DELAY_MS = 2_147_483_647
/** Manager-owned observers of a Session object's local state edges. */
export interface SessionOptions {
/** Catalog-discovered address selecting non-activating subagent transport. */
address?: SubagentAddress
/** Whether the exact direct parent Agent was live at the latest catalog read. */
parentAvailable?: boolean
/**
* First ACCEPTED prompt on a blank session (fires at most once, on the
* prompt RPC's success response): the manager mirrors the blank→false flip
@@ -119,6 +123,8 @@ export class Session implements SessionFace {
private dispatchesRev = 0
private dispatchesCache: { rev: number; value: ReadonlyMap<string, readonly CodeSubCall[]> } | null = null
private running = false
private address: SubagentAddress | undefined
private parentAvailable = false
/**
* Sticky send marker, private input of the composerPhase derivation: set
* synchronously before prompt()'s first await, never reset — the blank →
@@ -174,6 +180,8 @@ export class Session implements SessionFace {
private readonly options: SessionOptions = {},
) {
this.projections = options.projections ?? new ProjectionValueStore()
this.address = options.address
this.parentAvailable = options.parentAvailable ?? false
this.snapshotCache = this.buildSnapshot()
}
@@ -213,7 +221,21 @@ export class Session implements SessionFace {
this.notifier.markDirty()
let result: RpcResult<{ accepted: true }>
try {
result = (await this.api.sessions.prompt({ sessionId: this.sessionId, mode, content })).result
if (this.address === undefined) {
result = (await this.api.sessions.prompt({ sessionId: this.sessionId, mode, content })).result
} else if (this.address.mode === 'one-shot') {
result = {
ok: false,
error: {
code: 'subagent-not-resumable',
message: 'one-shot subagent conversations are read-only',
details: { childSessionId: this.address.childSessionId },
},
}
} else {
const routed = (await this.api.subagents.prompt({ ...this.address, content })).result
result = routed.ok ? { ok: true, value: { accepted: true } } : routed
}
} catch (error) {
result = transportError(error)
}
@@ -253,6 +275,19 @@ export class Session implements SessionFace {
* @returns the cancel result.
*/
async cancel(): Promise<RpcResult<{ accepted: true }>> {
if (this.address !== undefined) {
const result: RpcResult<{ accepted: true }> = {
ok: false,
error: {
code: 'subagent-delivery-unavailable',
message: 'subagent activation cancellation is unavailable',
details: { childSessionId: this.address.childSessionId },
},
}
this.promptError = { op: 'stop', error: result.error }
this.notifier.markDirty()
return result
}
let result: RpcResult<{ accepted: true }>
try {
result = (await this.api.sessions.cancel({ sessionId: this.sessionId })).result
@@ -318,9 +353,7 @@ export class Session implements SessionFace {
this.loadingOlder = true
this.notifier.markDirty()
try {
const { result } = await this.api.sessions.history({
sessionId: this.sessionId, beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES,
})
const { result } = await this.history({ beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES })
if (!result.ok) return // keep the window as-is; do not overwrite openError (open already succeeded)
const older = result.value.events
if (older.length === 0) {
@@ -479,6 +512,32 @@ export class Session implements SessionFace {
this.notifier.markDirty()
}
/**
* Install or clear the catalog-discovered transport address. A changed
* address rebuilds an already-open window through its new history route.
* @param address - direct parent/child address, or undefined for ordinary transport.
* @param parentAvailable - latest exact-parent availability hint.
*/
configureSubagent(address: SubagentAddress | undefined, parentAvailable = false): void {
const same = this.address?.parentSessionId === address?.parentSessionId
&& this.address?.childSessionId === address?.childSessionId
&& this.address?.mode === address?.mode
this.address = address
this.parentAvailable = parentAvailable
if (!same && this.openState !== 'cold') void this.resync()
else this.notifier.markDirty()
}
/**
* Update only the parent availability hint from a catalog refresh.
* @param available - whether the exact direct parent is live.
*/
handleSubagentParentAvailable(available: boolean): void {
if (this.parentAvailable === available) return
this.parentAvailable = available
this.notifier.markDirty()
}
/**
* Blank-bit relay from the authoritative summary source (list baseline and
* the session-added frame). Monotone: once any signal (local first send,
@@ -533,7 +592,7 @@ export class Session implements SessionFace {
this.openError = null
this.notifier.markDirty()
try {
let { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })
let { result } = await this.history({ maxMessages: PAGE_MESSAGES })
if (generation !== this.openGeneration) return
if (!result.ok) {
this.openState = 'error'
@@ -544,7 +603,7 @@ export class Session implements SessionFace {
// 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
result = (await this.history({ maxMessages: PAGE_MESSAGES })).result
if (generation !== this.openGeneration) return
if (result.ok) this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
}
@@ -621,7 +680,7 @@ export class Session implements SessionFace {
this.stitching = true
const generation = this.openGeneration
try {
const { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })
const { result } = await this.history({ 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, result.value.projections)
@@ -888,6 +947,9 @@ export class Session implements SessionFace {
codeDispatches: this.dispatchesCache.value,
queue: this.queueCache.value,
running: this.running,
subagent: this.address === undefined
? null
: { address: this.address, parentAvailable: this.parentAvailable },
composerPhase: derivePhase(
// Command lifecycle nodes are not conversation: running /permission
// or /plan on a fresh session keeps the hero (the client mirror of
@@ -905,6 +967,17 @@ export class Session implements SessionFace {
lastAgentError: this.lastAgentError,
}
}
/** Select ordinary or addressed history transport from the stored browser fact. */
private history(payload: { beforeSeq?: number; maxMessages?: number }): Promise<RpcResponse<{
events: HistoryEntry[]
hasMore: boolean
projections?: ProjectionsBaseline
}>> {
return this.address === undefined
? this.api.sessions.history({ sessionId: this.sessionId, ...payload })
: this.api.subagents.history({ ...this.address, ...payload })
}
}
/** Validate the plugin-owned payload at the session-event wire boundary. */

View File

@@ -132,6 +132,19 @@ export class FakeApiClient implements IApiClient {
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
}
onSubagentList: (payload: unknown) => Promise<RpcResponse<{ entries: never[]; parentAvailable: boolean }>>
= () => Promise.resolve(ok({ entries: [], parentAvailable: true }))
onSubagentHistory: (payload: unknown) => Promise<RpcResponse<{ events: never[]; hasMore: boolean }>>
= () => Promise.resolve(ok({ events: [], hasMore: false }))
onSubagentPrompt: (payload: unknown) => Promise<RpcResponse<{ messageId: never }>>
= () => Promise.resolve(ok({ messageId: 'fake-message' as never }))
readonly subagents: IApiClient['subagents'] = {
list: (payload: unknown) => this.record('subagent.list', payload, this.onSubagentList(payload)),
history: (payload: unknown) => this.record('subagent.history', payload, this.onSubagentHistory(payload)),
prompt: (payload: unknown) => this.record('subagent.prompt', payload, this.onSubagentPrompt(payload)),
}
readonly host: IApiClient['host'] = {
describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)),
pickDirectory: (payload: unknown) => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)),

View File

@@ -12,7 +12,13 @@ import { entries, plainTurn } from './event-script.ts'
const S1 = 'fk-m1' as SessionId
const S2 = 'fk-m2' as SessionId
type SummaryOver = Partial<{ updatedAt: number; running: boolean; blank: boolean; parentSessionId: SessionId }>
type SummaryOver = Partial<{
updatedAt: number
running: boolean
blank: boolean
parentSessionId: SessionId
origin: 'subagent'
}>
function summary(sessionId: SessionId, over: SummaryOver = {}) {
return { sessionId, updatedAt: 100, running: false, blank: false, ...over }
@@ -272,6 +278,259 @@ describe('host frame routing', () => {
})
})
describe('subagent catalogs', () => {
it('keeps a catalog-discovered child address across ordinary selection and status frames', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok({ items: [
summary(S1),
summary(S2, { parentSessionId: S1, origin: 'subagent' }),
] as never[] }))
api.onSubagentList = () => Promise.resolve(ok({
entries: [{
kind: 'child', id: S2, mode: 'continuable', label: 'worker',
activity: 'running', hasChildren: false,
}] as never[],
parentAvailable: true,
}))
const manager = new SessionManager(api)
await manager.refreshList()
await manager.refreshSubagents(S1)
manager.selectSubagent({ parentSessionId: S1, childSessionId: S2, mode: 'continuable' })
expect(manager.getListSnapshot().currentAddress).toEqual({
parentSessionId: S1, childSessionId: S2, mode: 'continuable',
})
expect(manager.get(S2).getSnapshot().subagent).toEqual({
address: { parentSessionId: S1, childSessionId: S2, mode: 'continuable' },
parentAvailable: true,
})
// Clicking the same child through an ordinary list-selection path must not
// erase the catalog-derived address and fall back to session.* transport.
manager.select(S2)
expect(manager.getListSnapshot().currentAddress).toEqual({
parentSessionId: S1, childSessionId: S2, mode: 'continuable',
})
expect(manager.get(S2).getSnapshot().subagent).toEqual({
address: { parentSessionId: S1, childSessionId: S2, mode: 'continuable' },
parentAvailable: true,
})
await manager.get(S2).open()
await manager.get(S2).prompt([{ type: 'text', text: 'continue' }], 'queue')
expect(api.callsOf('subagent.history')).toEqual([
{ parentSessionId: S1, childSessionId: S2, mode: 'continuable', maxMessages: 50 },
])
expect(api.callsOf('subagent.prompt')).toEqual([
{
parentSessionId: S1, childSessionId: S2, mode: 'continuable',
content: [{ type: 'text', text: 'continue' }],
},
])
expect(api.callsOf('session.history')).toEqual([])
expect(api.callsOf('session.prompt')).toEqual([])
const listCalls = api.callsOf('subagent.list').length
manager.handleHostEnvelope({
rpcId: 'child-complete' as never,
payload: { type: 'host/session-status', sessionId: S2, running: false },
})
expect(manager.getListSnapshot().subagentsByParent[S1]?.entries[0]).toMatchObject({
kind: 'child', id: S2, activity: 'inactive',
})
expect(api.callsOf('subagent.list')).toHaveLength(listCalls)
manager.handleHostEnvelope({
rpcId: 'child-detached' as never,
payload: { type: 'host/session-removed', sessionId: S2 },
})
expect(manager.getListSnapshot().items.find(item => item.sessionId === S2)).toMatchObject({
origin: 'subagent', parentSessionId: S1, running: false,
})
expect(manager.get(S2).getSnapshot()).toMatchObject({
removed: false,
subagent: {
address: { parentSessionId: S1, childSessionId: S2, mode: 'continuable' },
},
})
})
it('refetches debounced membership only while the parent catalog is open', async () => {
vi.useFakeTimers()
try {
const api = new FakeApiClient()
const manager = new SessionManager(api)
await manager.refreshSubagents(S1)
manager.setSubagentCatalogOpen(S1, true)
await Promise.resolve()
const baseline = api.callsOf('subagent.list').length
manager.handleHostEnvelope({
rpcId: 'child-added' as never,
payload: {
type: 'host/session-added', sessionId: S2, parentSessionId: S1, blank: false,
},
})
manager.handleHostEnvelope({
rpcId: 'child-added-again' as never,
payload: {
type: 'host/session-added', sessionId: 'fk-m3' as SessionId, parentSessionId: S1, blank: false,
},
})
await vi.advanceTimersByTimeAsync(50)
expect(api.callsOf('subagent.list')).toHaveLength(baseline + 1)
manager.setSubagentCatalogOpen(S1, false)
manager.handleHostEnvelope({
rpcId: 'child-added-closed' as never,
payload: {
type: 'host/session-added', sessionId: 'fk-m4' as SessionId, parentSessionId: S1, blank: false,
},
})
await vi.advanceTimersByTimeAsync(50)
expect(api.callsOf('subagent.list')).toHaveLength(baseline + 1)
} finally {
vi.useRealTimers()
}
})
it('marks a loaded parent row expandable only for a direct subagent publication', async () => {
const api = new FakeApiClient()
const root = 'fk-root' as SessionId
api.onSubagentList = () => Promise.resolve(ok({
entries: [
{
kind: 'child', id: S1, mode: 'continuable', label: 'parent',
activity: 'inactive', hasChildren: false,
},
{
kind: 'child', id: S2, mode: 'continuable', label: 'ordinary parent',
activity: 'inactive', hasChildren: false,
},
] as never[],
parentAvailable: true,
}))
const manager = new SessionManager(api)
await manager.refreshSubagents(root)
manager.handleHostEnvelope({
rpcId: 'nested-subagent' as never,
payload: {
type: 'host/session-added', sessionId: 'fk-grandchild' as SessionId,
parentSessionId: S1, origin: 'subagent', blank: false,
},
})
manager.handleHostEnvelope({
rpcId: 'ordinary-fork' as never,
payload: {
type: 'host/session-added', sessionId: 'fk-fork' as SessionId,
parentSessionId: S2, blank: false,
},
})
expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
{ kind: 'child', id: S1, hasChildren: true },
{ kind: 'child', id: S2, hasChildren: false },
])
})
it('preserves a live expandability hint across only the older in-flight catalog response', async () => {
const api = new FakeApiClient()
const root = 'fk-root' as SessionId
const response = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
api.onSubagentList = () => response.promise
const manager = new SessionManager(api)
const refresh = manager.refreshSubagents(root)
manager.handleHostEnvelope({
rpcId: 'nested-subagent' as never,
payload: {
type: 'host/session-added', sessionId: 'fk-grandchild' as SessionId,
parentSessionId: S1, origin: 'subagent', blank: false,
},
})
response.resolve(ok({
entries: [{
kind: 'child', id: S1, mode: 'continuable', label: 'parent',
activity: 'inactive', hasChildren: false,
}] as never[],
parentAvailable: true,
}))
await refresh
expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
{ kind: 'child', id: S1, hasChildren: true },
])
api.onSubagentList = () => Promise.resolve(ok({
entries: [{
kind: 'child', id: S1, mode: 'continuable', label: 'parent',
activity: 'inactive', hasChildren: false,
}] as never[],
parentAvailable: true,
}))
await manager.refreshSubagents(root)
expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
{ kind: 'child', id: S1, hasChildren: false },
])
})
it('replays status frames over an older in-flight catalog response', async () => {
const api = new FakeApiClient()
const root = 'fk-root' as SessionId
const response = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
api.onSubagentList = () => response.promise
const manager = new SessionManager(api)
const refresh = manager.refreshSubagents(root)
manager.handleHostEnvelope({
rpcId: 'child-stopped' as never,
payload: { type: 'host/session-status', sessionId: S1, running: false },
})
manager.handleHostEnvelope({
rpcId: 'child-started' as never,
payload: { type: 'host/session-status', sessionId: S2, running: true },
})
response.resolve(ok({
entries: [
{
kind: 'child', id: S1, mode: 'continuable', label: 'stopped',
activity: 'running', hasChildren: false,
},
{
kind: 'child', id: S2, mode: 'continuable', label: 'started',
activity: 'inactive', hasChildren: false,
},
] as never[],
parentAvailable: true,
}))
await refresh
expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
{ kind: 'child', id: S1, activity: 'inactive' },
{ kind: 'child', id: S2, activity: 'running' },
])
})
it('marks a detached catalog child inactive without requiring a selected address', async () => {
const api = new FakeApiClient()
api.onSubagentList = () => Promise.resolve(ok({
entries: [{
kind: 'child', id: S2, mode: 'continuable', label: 'worker',
activity: 'running', hasChildren: false,
}] as never[],
parentAvailable: true,
}))
const manager = new SessionManager(api)
await manager.refreshSubagents(S1)
manager.handleHostEnvelope({
rpcId: 'child-detached' as never,
payload: { type: 'host/session-removed', sessionId: S2 },
})
expect(manager.getListSnapshot().subagentsByParent[S1]?.entries).toMatchObject([
{ kind: 'child', id: S2, activity: 'inactive' },
])
})
})
describe('remaining branches', () => {
it('refreshList folds a transport throw into the error state', async () => {
const api = new FakeApiClient()
@@ -409,9 +668,17 @@ describe('remaining branches', () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } })
manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-added', blank: true, sessionId: S2, parentSessionId: S1 } })
manager.handleHostEnvelope({
rpcId: 'h2' as never,
payload: {
type: 'host/session-added', blank: true, sessionId: S2,
parentSessionId: S1, origin: 'subagent',
},
})
const items = manager.getListSnapshot().items
expect(items.find(e => e.sessionId === S2)).toMatchObject({ parentSessionId: S1, depth: 1 })
expect(items.find(e => e.sessionId === S2)).toMatchObject({
parentSessionId: S1, origin: 'subagent', depth: 1,
})
})
})
@@ -435,6 +702,21 @@ describe('connected generation', () => {
expect(api.callsOf('session.history').length).toBe(historyCallsBefore + 1)
})
})
it('reloads the durable parent address for a restored child selection', async () => {
const api = new FakeApiClient()
const address = {
parentSessionId: S1, childSessionId: S2, mode: 'continuable' as const,
}
const manager = new SessionManager(api, S2, address)
manager.handleConnected()
await vi.waitFor(() => {
expect(api.callsOf('subagent.list')).toContainEqual({ parentSessionId: S1 })
})
expect(manager.getListSnapshot().currentAddress).toEqual(address)
})
})
describe('waiting-approval list bit', () => {

View File

@@ -18,6 +18,7 @@ const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
({ seq, time: 1_700_000_000_000 + seq, ...e }) as unknown as SessionEvent
const SID = 'fk-s1' as SessionId
const PARENT = 'fk-parent' as SessionId
function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: Session } {
return { api, session: new Session(SID, api) }
@@ -580,6 +581,51 @@ describe('paging', () => {
})
describe('prompt and cancel errors', () => {
it('routes an addressed child through non-activating history and continuation prompt only', async () => {
const api = new FakeApiClient()
const session = new Session(SID, api, {
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
parentAvailable: true,
})
await session.open()
const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue')
const cancelled = await session.cancel()
expect(prompted).toEqual({ ok: true, value: { accepted: true } })
expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-delivery-unavailable' } })
expect(api.callsOf('subagent.history')).toEqual([
{ parentSessionId: PARENT, childSessionId: SID, mode: 'continuable', maxMessages: 50 },
])
expect(api.callsOf('subagent.prompt')).toEqual([
{
parentSessionId: PARENT, childSessionId: SID, mode: 'continuable',
content: [{ type: 'text', text: '继续' }],
},
])
expect(api.callsOf('session.history')).toEqual([])
expect(api.callsOf('session.prompt')).toEqual([])
expect(api.callsOf('session.cancel')).toEqual([])
expect(session.getSnapshot().subagent).toEqual({
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
parentAvailable: true,
})
})
it('keeps one-shot history readable without exposing prompt or cancel transport', async () => {
const api = new FakeApiClient()
const session = new Session(SID, api, {
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot' },
})
await session.open()
const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue')
expect(prompted).toMatchObject({ ok: false, error: { code: 'subagent-not-resumable' } })
expect(api.callsOf('subagent.history')).toEqual([
{ parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot', maxMessages: 50 },
])
expect(api.callsOf('subagent.prompt')).toEqual([])
})
it('sends content through session.prompt; composerPhase steps blank → engaging synchronously at send entry', async () => {
const { api, session } = makeSession()
// The blank → engaging edge fires before the RPC settles: the first-send

View File

@@ -3,8 +3,8 @@
* with derived titles), the migrated current-selection account (open
* validation, persisted mask semantics, cell resolution), scope-tree
* lifecycle (lazy mint / frozen survival / removed teardown with staged
* deferral — the stage follows list.current), binding identity, ancestry
* walk, create.
* deferral — the stage follows list.current), binding identity, breadcrumb
* projection, create.
*/
import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -28,7 +28,14 @@ function bench(): Bench {
}
/** Refresh the manager list from programmable rows and flush the microtask batch. */
type FeedRow = { id: string; cwd?: string; parentId?: string; running?: boolean; blank?: boolean }
type FeedRow = {
id: string
cwd?: string
parentId?: string
origin?: 'subagent'
running?: boolean
blank?: boolean
}
async function feedList(b: Bench, rows: FeedRow[]): Promise<void> {
b.api.onList = () => Promise.resolve(ok({
@@ -36,6 +43,7 @@ async function feedList(b: Bench, rows: FeedRow[]): Promise<void> {
sessionId: sid(r.id), updatedAt: 1, running: r.running ?? false, blank: r.blank ?? false,
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
...(r.parentId !== undefined ? { parentSessionId: sid(r.parentId) } : {}),
...(r.origin !== undefined ? { origin: r.origin } : {}),
})),
}) as never)
await b.svc.refresh()
@@ -51,12 +59,14 @@ describe('list store projection', () => {
})
await feedList(b, [
{ id: 's1', cwd: '/home/u/proj-a/' },
{ id: 's2', parentId: 's1', running: true },
{ id: 's2', parentId: 's1', origin: 'subagent', running: true },
])
const state = b.svc.list.getSnapshot()
expect(state.ids).toEqual(['s1', 's2'])
expect(state.byId[sid('s1')]).toMatchObject({ title: 'Durable title', displayTitle: 'Durable title', cwd: '/home/u/proj-a/' })
expect(state.byId[sid('s2')]).toMatchObject({ displayTitle: 's2', parentId: 's1', running: true })
expect(state.byId[sid('s2')]).toMatchObject({
displayTitle: 's2', parentId: 's1', origin: 'subagent', running: true,
})
expect(state.byId[sid('s2')]?.title).toBeUndefined()
})
@@ -351,18 +361,89 @@ describe('slot-store scope prune hook', () => {
})
})
describe('ancestry', () => {
it('walks parentId links root-first including self; broken links stop the walk', async () => {
describe('catalog-addressed navigation', () => {
it('uses catalog labels for a listed addressed route', async () => {
const b = bench()
b.api.onSubagentList = (payload) => {
const { parentSessionId } = payload as { parentSessionId: SessionId }
if (parentSessionId === sid('root')) {
return Promise.resolve(ok({
entries: [{
kind: 'child', id: sid('child'), mode: 'continuable', label: 'Child',
activity: 'inactive', hasChildren: true,
}] as never[],
parentAvailable: true,
}))
}
if (parentSessionId === sid('child')) {
return Promise.resolve(ok({
entries: [{
kind: 'child', id: sid('grandchild'), mode: 'continuable', label: 'Grandchild',
activity: 'inactive', hasChildren: false,
}] as never[],
parentAvailable: false,
}))
}
return Promise.resolve(ok({ entries: [], parentAvailable: false }))
}
await feedList(b, [
{ id: 'root', cwd: '/w/app' },
{ id: 'mid', parentId: 'root' },
{ id: 'leaf', parentId: 'mid' },
{ id: 'orphan', parentId: 'ghost' },
{ id: 'root' },
{ id: 'child', cwd: '/summary-child', parentId: 'root', origin: 'subagent' },
{ id: 'grandchild', cwd: '/summary-grandchild', parentId: 'child', origin: 'subagent' },
])
expect(b.svc.ancestry(sid('leaf')).map(s => s.id)).toEqual(['root', 'mid', 'leaf'])
expect(b.svc.ancestry(sid('orphan')).map(s => s.id)).toEqual(['orphan'])
expect(b.svc.ancestry(sid('ghost'))).toEqual([])
await b.svc.refreshSubagents(sid('root'))
await b.svc.refreshSubagents(sid('child'))
b.svc.openSubagent({
parentSessionId: sid('child'), childSessionId: sid('grandchild'), mode: 'continuable',
})
expect(b.svc.list.getSnapshot().byId[sid('child')]?.displayTitle).toBe('Child')
expect(b.svc.list.getSnapshot().byId[sid('grandchild')]?.displayTitle).toBe('Grandchild')
})
it('projects a directly opened descendant route without retaining ancestor scopes or addresses', async () => {
const b = bench()
b.api.onSubagentList = (payload) => {
const { parentSessionId } = payload as { parentSessionId: SessionId }
if (parentSessionId === sid('root')) {
return Promise.resolve(ok({
entries: [{
kind: 'child', id: sid('child'), mode: 'continuable', label: 'Child',
activity: 'inactive', hasChildren: true,
}] as never[],
parentAvailable: true,
}))
}
if (parentSessionId === sid('child')) {
return Promise.resolve(ok({
entries: [{
kind: 'child', id: sid('grandchild'), mode: 'continuable', label: 'Grandchild',
activity: 'inactive', hasChildren: false,
}] as never[],
parentAvailable: false,
}))
}
return Promise.resolve(ok({ entries: [], parentAvailable: false }))
}
await feedList(b, [{ id: 'root' }])
await b.svc.refreshSubagents(sid('root'))
await b.svc.refreshSubagents(sid('child'))
b.svc.openSubagent({
parentSessionId: sid('child'), childSessionId: sid('grandchild'), mode: 'continuable',
})
const list = b.svc.list.getSnapshot()
expect(list.ids).toEqual([sid('root')])
expect(list.byId[sid('child')]).toMatchObject({ parentId: sid('root'), origin: 'subagent' })
expect(list.byId[sid('grandchild')]).toMatchObject({ parentId: sid('child'), origin: 'subagent' })
expect(b.svc.binding(sid('child'))).toBeUndefined()
expect(b.svc.subagentAddress(sid('child'))).toBeUndefined()
b.svc.open(sid('child'))
expect(b.svc.list.getSnapshot().current).toBe(sid('child'))
expect(b.svc.subagentAddress(sid('child'))).toEqual({
parentSessionId: sid('root'), childSessionId: sid('child'), mode: 'continuable',
})
})
})

View File

@@ -52,6 +52,7 @@ export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot
pending: [],
queue: [],
running: false,
subagent: null,
composerPhase: 'active',
removed: false,
openState: 'open',

View File

@@ -5,6 +5,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationSnapshot, ISessions, ObservableSnapshot, ProjectionsFace, SessionFace, SessionId,
SessionListState, SessionProvideDescriptor, SessionSearchResultItem, SessionSummary, SnapshotStore,
SubagentAddress,
} from '@deepseek-ai/dsh-client-runtime/client'
// The double reports the wire schema's own search bound, like the production
// service — a transport-varying limit would be a fiction no client can see.
@@ -171,8 +172,12 @@ export class TestSessions implements ISessions {
/** The production provide channel (roster, materialization rules, current projection) — no test-side mirror. */
private readonly channel: SessionProvideChannel
/** Calls observed on the service-level face (open/clear/search/fork), newest last. */
readonly calls: { method: 'open' | 'clear' | 'search' | 'fork'; args: unknown[] }[] = []
/** Calls observed on the service-level face, newest last. */
readonly calls: {
method: 'open' | 'openSubagent' | 'setSubagentCatalogOpen' | 'refreshSubagents'
| 'clear' | 'search' | 'fork'
args: unknown[]
}[] = []
/** The wire schema's `session.search` result bound (production parity). */
readonly searchResultLimit = SESSION_SEARCH_RESULT_LIMIT
@@ -187,6 +192,7 @@ export class TestSessions implements ISessions {
constructor(private readonly stabilize: Stabilizer, private readonly rootCtx: Context) {
this.list = createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'ready',
subagentsByParent: {}, currentAddress: undefined,
})
this.channel = new SessionProvideChannel({
rebuildBundles: () => {
@@ -392,13 +398,46 @@ export class TestSessions implements ISessions {
open(id: SessionId): void {
this.calls.push({ method: 'open', args: [id] })
this.require(id)
this.list.update((draft) => { draft.current = id })
this.list.update((draft) => {
draft.current = id
draft.currentAddress = undefined
})
}
/** Open an existing fixture through its catalog address. */
openSubagent(address: SubagentAddress): void {
this.calls.push({ method: 'openSubagent', args: [address] })
this.require(address.childSessionId)
this.list.update((draft) => {
draft.current = address.childSessionId
draft.currentAddress = address
})
}
/** Resolve the current fixture's retained catalog address. */
subagentAddress(id: SessionId): SubagentAddress | undefined {
const address = this.list.getSnapshot().currentAddress
return address?.childSessionId === id ? address : undefined
}
/** Record catalog consumption; fixture callers drive snapshots explicitly. */
setSubagentCatalogOpen(parentSessionId: SessionId, open: boolean): void {
this.calls.push({ method: 'setSubagentCatalogOpen', args: [parentSessionId, open] })
}
/** Record a catalog refresh; fixture callers drive snapshots explicitly. */
refreshSubagents(parentSessionId: SessionId): Promise<void> {
this.calls.push({ method: 'refreshSubagents', args: [parentSessionId] })
return Promise.resolve()
}
/** Clear the current selection (recorded; the production no-session flow). */
clear(): void {
this.calls.push({ method: 'clear', args: [] })
this.list.update((draft) => { draft.current = undefined })
this.list.update((draft) => {
draft.current = undefined
draft.currentAddress = undefined
})
}
/**

View File

@@ -201,13 +201,29 @@ describe('sessions', () => {
await runtime.dispose()
})
it('records service-face calls; open() moves selection, clear() empties it, and fork() echoes the source', async () => {
it('records service-face calls and retains catalog addresses only for addressed selection', async () => {
const runtime = await runtimeWithFrame()
await runtime.sessions.add({ id: 's1' })
await runtime.sessions.add({ id: 's2' })
const address = {
parentSessionId: 's2' as SessionId,
childSessionId: 's1' as SessionId,
mode: 'continuable' as const,
}
runtime.sessions.openSubagent(address)
await runtime.flush()
expect(runtime.sessions.list.getSnapshot()).toMatchObject({ current: 's1', currentAddress: address })
expect(runtime.sessions.subagentAddress('s1' as SessionId)).toEqual(address)
expect(runtime.sessions.subagentAddress('s2' as SessionId)).toBeUndefined()
await runtime.sessions.updateSummary('s1', { displayTitle: 'renamed', running: true })
expect(runtime.sessions.list.getSnapshot().byId['s1' as SessionId])
.toMatchObject({ displayTitle: 'renamed', running: true })
runtime.sessions.setSubagentCatalogOpen('s2' as SessionId, true)
await runtime.sessions.refreshSubagents('s2' as SessionId)
runtime.sessions.open('s1' as SessionId)
await runtime.flush()
expect(runtime.sessions.list.getSnapshot().current).toBe('s1')
expect(runtime.sessions.list.getSnapshot().currentAddress).toBeUndefined()
runtime.sessions.clear()
await runtime.flush()
expect(runtime.sessions.list.getSnapshot().current).toBeUndefined()
@@ -215,6 +231,9 @@ describe('sessions', () => {
sessionId: 's1' as SessionId, atSeq: 7, increaseTitle: true,
})).resolves.toBe('s1')
expect(runtime.sessions.calls).toEqual([
{ method: 'openSubagent', args: [address] },
{ method: 'setSubagentCatalogOpen', args: ['s2', true] },
{ method: 'refreshSubagents', args: ['s2'] },
{ method: 'open', args: ['s1'] },
{ method: 'clear', args: [] },
{ method: 'fork', args: [{ sessionId: 's1', atSeq: 7, increaseTitle: true }] },

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-command/README.md
README.md: 64d06f1d9baae98ef31c7e2a62242eda6a8174da
README.zh.md: 0cec3b8c8f7baf2fb1c408bccfe8937aa78e4bf9
README.md: c892f2f244d7924014ad1b4d6e9fe16ff4e044e4
README.zh.md: ed607de783e833eed94fba09bc20c74375711a4f

View File

@@ -6,7 +6,7 @@ Client command surface (`ctx.command`): the session-keyed command-directory cach
`src/client/contract.ts` is the frozen business face: `CommandServiceContract.register(name, spec)` and `decorate(name, spec)` are everything a business package consumes; `CommandUiSpec{options, onSelect}` keeps popup data self-served — the shell component is this package's and business never sees it. A contribution is a client-owned command (a host-name collision fails loud); a decoration hangs a bare-invocation popup on an EXISTING host command — the host keeps its catalog row, argument claim (space / argued enter), and lifecycle logging, and a decorated name with no host row in the session's directory simply never fires. Command kinds derive per dispatch, never per registration: a host descriptor with `input` is leadingInput, a registered `CommandUiSpec` is popupSelect, everything else is execute.
`CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session: every session is agent-backed, so `command.list({sessionId})` is the only address shape and the source's scope-birth `warm` hook prewarms the session's entry. Entries are soft-invalidated by the `commands/changed` typed event (old snapshot serves while the repull flies), hard-invalidated by `connection/reset`, epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt.
`CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session. Ordinary sessions fetch through `command.list({sessionId})`, and the source's scope-birth `warm` hook prewarms the session's entry. Catalog-addressed continuable children resolve an empty command directory locally: `command.list` is Agent-bound, so prewarming it would activate a child merely to view persisted history. Entries are soft-invalidated by the `commands/changed` typed event (old snapshot serves while the repull flies), hard-invalidated by `connection/reset`, epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt.
`PopupSelectController` (`src/client/popup.ts`) is the headless shell state: `PopupSelectView` self-registers into `conversation.input.overlay` (the SlotMap key is ui-conversation's; this package pulls the declaration in with a type-only import — no runtime edge). The shell is a transient layer holding focus while open; token-segment consumption after onSelect runs both branches through `consumeTokenSegment` (menu-path span CAS, enter-path bare-token equality) against the draft face the wiring layer binds via `bindDraft`.

View File

@@ -6,7 +6,7 @@
`src/client/contract.ts` 是冻结的业务表层:`CommandServiceContract.register(name, spec)``decorate(name, spec)` 是业务包消费的全部内容;`CommandUiSpec{options, onSelect}` 让 popup 数据自给自足——壳组件归本包所有业务永远见不到它。contribution 是 client 自有命令(与 host 同名碰撞即 fail-louddecoration装饰则把裸调用 popup 挂在**已存在的** host 命令上——host 保留目录行、带参 claimspace / 带参 enter与生命周期记账被装饰的名字若在会话目录中无 host 行则装饰永不触发。命令三型按每次派发派生,绝不在注册时定型:带 `input` 的 host descriptor 是 leadingInput注册了 `CommandUiSpec` 的是 popupSelect其余全部是 execute。
`CommandDirectory``src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key:每个会话恒为 agent-backed因此 `command.list({sessionId})` 是唯一的寻址形状source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。缓存项由 `commands/changed` 类型化事件软失效(重拉在途期间旧快照继续服务),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。
`CommandDirectory``src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key。普通会话通过 `command.list({sessionId})` 拉取source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。由目录寻址的可继续子代理会在客户端解析为空命令目录:`command.list` 绑定 Agent若预热它就会仅因查看持久化历史而激活子代理。缓存项由 `commands/changed` 类型化事件软失效(重拉在途期间旧快照继续服务),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。
`PopupSelectController``src/client/popup.ts`)是无头的壳状态:`PopupSelectView` 自行注册进 `conversation.input.overlay`SlotMap key 归 ui-conversation 所有;本包只以 type-only 导入引入该声明——没有运行时依赖边。壳是打开期间持有焦点的瞬态层onSelect 之后的 token 片段消费在两条分支上都经 `consumeTokenSegment` 执行(菜单路径做 span CAS回车路径做裸 token 相等比较),作用于接线层经 `bindDraft` 绑定的草稿表层。

View File

@@ -43,6 +43,7 @@ export class CommandService extends Service implements CommandServiceContract {
const connection = ctx.get('connection') as ConnectionHandle | undefined
if (connection === undefined) throw new Error('ui-command: connection service unavailable')
this.directory = new CommandDirectory(async (sessionId) => {
if (this.sessions().subagentAddress(sessionId) !== undefined) return []
const { result } = await connection.api.commands.list({ sessionId })
if (!result.ok) throw new Error(`command.list failed: ${result.error.code}: ${result.error.message}`)
return result.value.commands

View File

@@ -37,6 +37,7 @@ interface BenchOptions {
/** Scripted catalog per list payload; default serves the fixed catalogs by session. */
commands?: (payload: { sessionId: SessionId }) => Promise<{ commands: CommandDescriptor[] }>
execute?: (payload: { sessionId: SessionId; line: string }) => Promise<ExecuteValue>
addressed?: SessionId
}
async function bench(opts: BenchOptions = {}) {
@@ -67,11 +68,14 @@ async function bench(opts: BenchOptions = {}) {
return () => { registered.delete(key) }
},
})
// Real scope tags behind a fake sessions face (scope/scopeOf are all the service reads).
// Real scope tags behind a fake sessions face.
const scopes = new Map<SessionId, { ctx: Context; fiber: { dispose(): Promise<void> } }>()
ctx.provide('sessions', {
scope: (id: SessionId) => scopes.get(id)?.ctx,
scopeOf: (c: Context) => scopeOf(c),
subagentAddress: (id: SessionId) => id === opts.addressed
? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const }
: undefined,
})
ctx.provide('connection', { api })
/** Notices the fake conversation face collected (runDetached routing). */
@@ -154,6 +158,12 @@ describe('registration', () => {
})
describe('candidates', () => {
it('does not fetch Agent-bound commands for an addressed child', async () => {
const b = await bench({ addressed: sid('child') })
await expect(b.warm(proj('child'))).resolves.toBeUndefined()
expect(b.listCalls).toEqual([])
})
it('pulls the session catalog; prefix filter and hint mapping apply', async () => {
const { source, listCalls } = await bench()
const list = await source.candidates(proj('s1'), req('g'))

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: 845e12d760326b97a7e1fbffbd1655a73c5b5174
README.zh.md: eab8d6663848b1130e41e2d581f13ad93a50b545
README.md: d92202e6dc2b1003db0cdf97910746a0de133357
README.zh.md: a75ee75a5fb2b3d8e5278283b6b6da15bf9c02cd

View File

@@ -12,6 +12,8 @@ The view ring IS a slot: the conversation registration declares the `'conversati
Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission <preset>` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing.
The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership.
Logged non-user messages render as a default-collapsed `上下文注入` disclosure. It shares the Tool calls header geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap, shows inline JSON for both `content` and `source`, and synthesizes no tool state, summary, or keyed toolview dispatch ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)).
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
@@ -30,7 +32,7 @@ A `grep`/`glob` call declaring the `search` render intent renders its result inl
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`/`openFile`) 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); 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 plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as 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 standing 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.
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: 0`before Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as 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 standing 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.
`QueueDock` is the terminal input-dock entry at `order: 20`. It hides while empty, renders one pending row directly, and defaults two or more rows to a collapsed `"<n> 条排队消息"` header whose button expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Each visible row remains a single-line preview with its exact-occurrence edit and delete actions.

View File

@@ -10,6 +10,8 @@
视图环本身就是 slot会话注册声明 `'conversation.view'` 列表 slotSession scope并将其列在 `children` 表中ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: <active id>`);视图标签页从环账本的注册选项(`id``order``label`投影而来。聊天视图是该包package自身的环配置项其他插件ui-trajectory通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView``ViewEntry``ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。
会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含当前对话 `session`ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。
已记录的非用户消息渲染为默认折叠的 `上下文注入` 展开项。它通过包内部的 `DisclosureRow``ToolRow` 共享 Tool calls 标题栏的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px超出后滚动并以内联 JSON 展示 `content``source`,且不会合成工具状态、摘要或键控 toolview 分发([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md))。
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect``Mount temporary Plugin``Unmount temporary Plugin`mount 行保留 code 变体的可展开源码渲染。
@@ -30,7 +32,7 @@
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位未实例化会话同样点亮镜像该阻塞状态其优先级高于运行中圆环直至问题解决。未决等待完全离开消息流问题ui-question与审批ApprovalPanel都经编辑器接管作答不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数key 缺席即隐藏 chipchip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission <preset>`,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用取消、Escape、关闭按钮与点击遮罩都不会提交命令。
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']``TodoRow` 占用 `'conversation.chat.toolview'``todo_write` key摘要该次调用「试图写入」的内容从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock``order: -1` 占用 `'conversation.input.dock'` 列表 slot位于队列行之上),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock包括这条计划条。
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']``TodoRow` 占用 `'conversation.chat.toolview'``todo_write` key摘要该次调用「试图写入」的内容从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock``order: 0` 占用 `'conversation.input.dock'` 列表 slot位于 Goal 与 Queue 之前),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock包括这条计划条。
`QueueDock``order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `"<n> 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded``aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。每条可见行仍是单行预览,并提供针对精确单次入队项的编辑和删除操作。

View File

@@ -165,7 +165,11 @@ export function apply(ctx: Context): void {
// the resident parent keeps Hero and composer layout identity stable.
slots.register({
name: 'conversation.session',
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
locale: NS,
children: {
'conversation.view': { kind: 'list', scope: 'session' },
'conversation.session.header.actions': { kind: 'list', scope: 'session' },
},
store: chatStore,
inject: (sessionId: SessionId, _actions: BoundActions<typeof chatStore>): ConversationSessionInjected => ({
views: {
@@ -174,6 +178,7 @@ export function apply(ctx: Context): void {
version: () => slots.getVersion('conversation.view'),
},
bindDraftMirror: write => inputHub.shell(sessionId).bindMirror(write),
open: (id) => { sessions.open(id) },
}),
}, ConversationSession)

View File

@@ -3,7 +3,7 @@ import type { ReactNode, RefObject } from 'react'
import type {
InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts'
import type { createChatStore } from '../stores.ts'
@@ -17,6 +17,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* remounted when the current session id changes.
*/
'conversation.session': { kind: 'single'; scope: 'session'; owner: ConversationSessionOwnerProps }
/** Session-header actions contributed by feature plugins. */
'conversation.session.header.actions': { kind: 'list'; scope: 'session'; owner: ConversationHeaderActionOwnerProps }
/**
* The conversation view ring: one list entry per view tab (chat here;
* trajectory/waterfall from ui-trajectory), rendered one-at-a-time by
@@ -135,6 +137,9 @@ export interface ConversationSessionOwnerProps {
wrapActiveBody?: (view: ReactNode) => ReactNode
}
/** Header actions derive their state from the standard session/global kit. */
export interface ConversationHeaderActionOwnerProps {}
/**
* The input-region slot currency (plan §1.4): dock/left/right entries read
* the conversation snapshot and the live input state as owner props (both
@@ -244,6 +249,8 @@ export interface ConversationSessionInjected {
}
/** Bind the input machine's draft persistence mirror to the session store. */
bindDraftMirror: (write: (text: string) => void) => () => void
/** Select a real Session through the runtime navigation owner. */
open: (sessionId: SessionId) => void
}
/**
@@ -329,6 +336,8 @@ export type ComposerBarProps =
*/
export interface ComposerChainProps {
interactions: readonly PendingInteraction[]
/** Current conversation facts for feature-owned takeover selectors. */
session: ConversationSnapshot | undefined
}
/**
@@ -350,9 +359,10 @@ export type ConversationSlotProps =
/** Full strict-session content props: per-session store, view ring, callbacks, and the locale seat. */
export type ConversationSessionSlotProps =
PropsRuntime<'conversation.session'>
& PropsRenderSlots<'conversation.view'>
& PropsRenderSlots<'conversation.view' | 'conversation.session.header.actions'>
& PropsStore<ChatStore>
& ConversationSessionInjected
& PropsLocale<'conversation'>
/** The pending approval carrier the owner dispatches into the composer chain. */
export type ApprovalWait = PendingWait<'approval'>

View File

@@ -30,6 +30,7 @@ export const zh = {
'access.confirm.enable': '启用 Full access',
'hero.headline': '开始构建吧',
'hero.chooseWorkspace': '选择工作区',
'session.hierarchy': '会话层级',
'details.title': '详情',
'details.close': '关闭详情',
'details.empty': '点击消息流中的工具行查看详情',
@@ -129,6 +130,7 @@ export const en = {
'access.confirm.enable': 'Enable Full access',
'hero.headline': 'Let\'s start building',
'hero.chooseWorkspace': 'Choose workspace',
'session.hierarchy': 'Session hierarchy',
'details.title': 'Details',
'details.close': 'Close details',
'details.empty': 'Click a tool row in the message flow to view its details',

View File

@@ -1,4 +1,4 @@
/* Conversation column skeleton: header (session title + tabs) over the view
/* Conversation column skeleton: header (breadcrumb row only for subagents not fork + tabs) over the view
area, composer InputBar at the bottom. Column width/squeeze is layout's;
this fills its cell. Figma: Header 39:27730 (83px two-row), tabs 13px with
a 3px active bar. */
@@ -26,21 +26,62 @@
.titleRow {
display: flex;
align-items: center;
gap: 10px;
min-height: 32px;
}
.sessionTitle {
.crumbs {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
max-width: 100%;
overflow: hidden;
margin: 0;
padding: 4px 8px;
white-space: nowrap;
}
.crumbSeg {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.crumbSep {
/* figma: "/" separators are 14px caption gray (75:7903), one tint lighter than crumb text. */
color: var(--dsw-alias-label-caption);
font-size: 14px;
line-height: 20px;
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
.crumb {
max-width: 220px;
overflow: hidden;
padding: 4px 8px;
border: none;
border-radius: 12px;
background: transparent;
font-size: 14px;
line-height: 20px;
color: var(--dsw-alias-label-tertiary);
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
.crumb:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}
.crumbCurrent {
font-weight: 500;
color: var(--dsw-alias-label-primary);
cursor: default;
}
.headerActions {
display: flex;
flex: none;
align-items: center;
gap: 8px;
}
/* figma Tab_Group 34:11441: 35px strip, gap 36, pad-left 8, tabs bottom-aligned. */

View File

@@ -153,7 +153,7 @@ export function ConversationRoot({
const phase = settling ? 'settling' : hero ? 'hero' : 'active'
const composer = renderSlotChain(
'conversation.composer',
{ interactions: pending },
{ interactions: pending, session },
{ fallback: composerBar, overlay: true },
)

View File

@@ -2,21 +2,51 @@
import { useEffect, useSyncExternalStore, type ReactNode } from 'react'
import clsx from 'clsx'
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSessionSlotProps } from '../contract/slots.ts'
import css from './ConversationRoot.module.css'
/** Full props composed from the strict session slot contract. */
export type ConversationSessionProps = ConversationSessionSlotProps
interface Breadcrumb {
readonly id: SessionId
readonly displayTitle: string
}
function deriveAncestry(list: SessionListState, id: SessionId): readonly Breadcrumb[] {
const chain: Breadcrumb[] = []
const seen = new Set<SessionId>()
let cursor: SessionId | undefined = id
while (cursor !== undefined) {
if (seen.has(cursor)) break
seen.add(cursor)
const summary: SessionSummary | undefined = list.byId[cursor]
if (summary === undefined) break
chain.unshift({ id: summary.id, displayTitle: summary.displayTitle })
if (summary.origin !== 'subagent') break
cursor = summary.parentId
}
return chain
}
function equalBreadcrumbs(left: readonly Breadcrumb[], right: readonly Breadcrumb[]): boolean {
return left.length === right.length
&& left.every((item, index) => {
const other = right.at(index)
return other !== undefined && item.id === other.id && item.displayTitle === other.displayTitle
})
}
export function ConversationSession({
sessionId, useSession, useSessions, useInput, inputActions, useStore, actions,
renderSlot, views, bindDraftMirror, wrapActiveBody,
renderSlot, views, bindDraftMirror, open, wrapActiveBody, t,
}: ConversationSessionProps) {
useSyncExternalStore(views.subscribe, views.version)
const tabs = views.list()
const activeId = useStore(s => s.view) ?? 'chat'
const active = tabs.find(view => view.id === activeId) ?? tabs[0]
const title = useSessions(s => s.byId[sessionId]?.displayTitle ?? sessionId)
const ancestry = useSessions(s => deriveAncestry(s, sessionId), equalBreadcrumbs)
const composerPhase = useSession(s => s.composerPhase)
const blank = useSession(s => s.blank)
const inputState = useInput(s => s)
@@ -56,7 +86,28 @@ export function ConversationSession({
{!hideChrome && (
<>
<div className={css.titleRow}>
<h1 className={css.sessionTitle}>{title}</h1>
<nav className={css.crumbs} aria-label={t('session.hierarchy')}>
{ancestry.map((summary, index) => {
const last = index === ancestry.length - 1
return (
<span key={summary.id} className={css.crumbSeg}>
{index > 0 && <span className={css.crumbSep}>/</span>}
<button
type="button"
className={clsx(css.crumb, last && css.crumbCurrent)}
disabled={last}
onClick={() => { open(summary.id) }}
>
{summary.displayTitle}
</button>
</span>
)
})}
{ancestry.length === 0 && <span className={css.crumbCurrent}>{sessionId}</span>}
</nav>
<div className={css.headerActions}>
{renderSlot('conversation.session.header.actions', {})}
</div>
</div>
{tabs.length > 1 && (
<div className={css.tabs} role="tablist">

View File

@@ -44,6 +44,7 @@ export function InputBar({
const commandMenuOpen = useMenuLauncher(source => source === 'command')
const promptError = useSession(s => s.promptError) ?? null
const running = useSession(s => s.running) ?? false
const subagent = useSession(s => s.subagent) ?? null
const removed = useSession(s => s.removed) ?? false
// Plan mode swaps the textarea placeholder (the projection is the folded
// host value; owner-prop placeholders — hero, session-unavailable — win).
@@ -283,13 +284,15 @@ export function InputBar({
if (el !== null) toggleCommandMenu?.(selectionOf(el))
}
const primaryLabel = running ? t('input.stop') : t('input.send')
const ordinary = subagent === null
const stopping = running && ordinary
const primaryLabel = stopping ? t('input.stop') : t('input.send')
const onPrimary = (): void => {
if (inputActions === undefined || stop === undefined) return // absent machine: the button is disabled
if (running) {
stop()
if (stopping) {
stop?.()
return
}
if (inputActions === undefined) return // absent machine: the button is disabled
/* v8 ignore next -- defensive: the primary button is disabled while empty||disabled, so a click cannot reach the false arm. */
if (!empty && !disabled && !machineBusy) inputActions.submit()
}
@@ -465,11 +468,11 @@ export function InputBar({
className={css.primary}
aria-label={primaryLabel}
title={primaryLabel}
disabled={!running && (empty || disabled || machineBusy)}
disabled={stopping ? stop === undefined : empty || disabled || machineBusy}
onMouseDown={keepFocus}
onClick={onPrimary}
>
{running ? (
{stopping ? (
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
<rect x="3" y="3" width="10" height="10" rx="3" fill="currentColor" />
</svg>

View File

@@ -1,6 +1,6 @@
/* Todo strip in the composer context stack (Figma 9:959): tip surface,
14px radius, status icons + secondary item labels. It shares the composer
card geometry and adds the dock inset on both sides. */
/* Todo strip in the composer context stack (Figma 1236:32276): tip surface,
14px radius, status icons + secondary item labels. Its visible card aligns
with the GoalBar and the Queue panel inside their shared dock column. */
.root {
box-sizing: border-box;
@@ -12,11 +12,15 @@
var(--dsh-composer-side-clearance) -
var(--dsh-composer-side-clearance) -
var(--dsh-composer-dock-inset) -
var(--dsh-composer-dock-inset) -
var(--dsh-composer-dock-inset) -
var(--dsh-composer-dock-inset)
);
max-width: calc(
var(--dsh-composer-card-max-width) -
var(--dsh-composer-dock-inset) -
var(--dsh-composer-dock-inset) -
var(--dsh-composer-dock-inset) -
var(--dsh-composer-dock-inset)
);
border: 1px solid var(--dsw-alias-border-l1);

View File

@@ -138,10 +138,10 @@ export const todoDockEntry = {
name: 'conversation-todo-dock',
inject: ['slots', 'conversation'],
/**
* Register the plan strip between the goal and queue entries (order 10).
* Register the plan strip before the goal and queue entries (order 0).
* @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: 10, locale: NS }, TodoDock)
ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: 0, locale: NS }, TodoDock)
},
}

View File

@@ -20,7 +20,7 @@
* suite only proves the assembled wiring.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, waitFor } from '@testing-library/react'
import { cleanup, fireEvent, waitFor, within } from '@testing-library/react'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import type { ISession, SessionId, TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
@@ -256,14 +256,17 @@ describe('prompt rejection through the assembled composer', () => {
})
describe('title projection across assembled surfaces', () => {
it('one summary update re-labels the current-session heading', async () => {
it('one summary update re-labels the current-session crumb', async () => {
const runtime = await bench([])
const view = runtime.renderRoot()
expect(view.getByRole('heading', { name: 'S', level: 1 })).toBeTruthy()
const hierarchy = view.getByRole('navigation', { name: '会话层级' })
expect(within(hierarchy).getByRole('button', { name: 'S' }).hasAttribute('disabled')).toBe(true)
await runtime.sessions.updateSummary(SID, { displayTitle: '修订标题', title: '修订标题' })
await waitFor(() => { expect(view.getByRole('heading', { name: '修订标题', level: 1 })).toBeTruthy() })
expect(view.queryByRole('heading', { name: 'S', level: 1 })).toBeNull()
await waitFor(() => {
expect(within(hierarchy).getByRole('button', { name: '修订标题' }).hasAttribute('disabled')).toBe(true)
})
expect(within(hierarchy).queryByRole('button', { name: 'S' })).toBeNull()
await runtime.dispose()
})
})

View File

@@ -70,7 +70,7 @@ function snapshotWith(
sessionId: SID, nodes, partial: null, runningCalls, codeDispatches,
pending: [], queue: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
}
}
@@ -92,7 +92,7 @@ async function bench(snapshot: ConversationSnapshot) {
ids: [SID],
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, waitingApproval: false, blank: false, updatedAt: 1 } },
current: SID,
phase: 'ready',
phase: 'ready', subagentsByParent: {}, currentAddress: undefined,
})
const scoped = { send: vi.fn(async () => {}), cancel: vi.fn(async () => {}) }
const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }

View File

@@ -34,7 +34,7 @@ function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
}
}
@@ -248,6 +248,8 @@ describe('bash sample row', () => {
},
current: undefined,
phase: 'ready',
subagentsByParent: {},
currentAddress: undefined,
})
}

View File

@@ -35,7 +35,7 @@ function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
}
}
@@ -93,7 +93,7 @@ const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({
/** Empty sessions-list hook for the global standard-kit seat. */
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready' })
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
return bindSnapshotSelector(store)
}

View File

@@ -96,6 +96,8 @@ describe('tails', () => {
byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, waitingApproval: false, blank: false, updatedAt: 0 } },
current: undefined,
phase: 'ready',
subagentsByParent: {},
currentAddress: undefined,
})
const props = (block: RunningToolCall | ToolResultNode) => ({
callId: 'c1', toolName: 'bash', block, openFile: vi.fn(),

View File

@@ -156,6 +156,8 @@ describe('FileMutationRow diff card', () => {
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd: '/w/app' } },
current: SID,
phase: 'ready',
subagentsByParent: {},
currentAddress: undefined,
})
const rowProps = (block: RunningToolCall | ToolResultNode, toolName = 'edit'): FileMutationRowProps => ({
@@ -301,12 +303,14 @@ describe('DetailsPanel diff Output section', () => {
const chat = createChatStore().create()
if (selection !== null) chat.actions.select(selection)
const sessions = createSnapshotStore<SessionListState>(cwd === undefined
? { ids: [], byId: {}, current: undefined, phase: 'ready' }
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }
: {
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } },
current: SID,
phase: 'ready',
subagentsByParent: {},
currentAddress: undefined,
})
const workspaces = createSnapshotStore<WorkspaceListState>({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
@@ -334,7 +338,7 @@ describe('DetailsPanel diff Output section', () => {
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null, ...over,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
}
}

View File

@@ -26,7 +26,7 @@ function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
}
}
@@ -76,7 +76,7 @@ describe('render branch tails', () => {
const chat = createChatStore().create()
chat.actions.select({ turnSeq: 1, callId: 'ghost' } satisfies SelectionTarget)
const emptyList = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready' })
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
const emptyWorkspaces = createSnapshotStore<WorkspaceListState>({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
@@ -113,7 +113,7 @@ describe('render branch tails', () => {
const chat = createChatStore().create()
chat.actions.select({ turnSeq: 8, callId: 'p1:code:1', toolName: 'read' } satisfies SelectionTarget)
const emptyList = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready' })
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
const emptyWorkspaces = createSnapshotStore<WorkspaceListState>({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,

View File

@@ -26,7 +26,7 @@ function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): Conversation
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null,
promptError: null, blank: false, subagent: null, lastAgentError: null,
...overrides,
}
}
@@ -41,6 +41,7 @@ interface BenchOptions {
permissions?: { options: { value: string; name: string; description?: string }[]; currentValue: string }
draft?: string
running?: boolean
subagent?: Exclude<ConversationSnapshot['subagent'], null>
disabled?: boolean
promptError?: ConversationSnapshot['promptError']
variant?: 'hero' | 'composer'
@@ -76,6 +77,7 @@ function bench(over?: BenchOptions) {
if (over?.draft !== undefined && over.draft !== '') shell.setDraft(over.draft)
const session = createSnapshotStore<ConversationSnapshot>(snapshotOf({
running: over?.running ?? false,
subagent: over?.subagent ?? null,
removed: over?.disabled ?? false,
promptError: over?.promptError ?? null,
}))
@@ -94,6 +96,7 @@ function bench(over?: BenchOptions) {
useSession: bindSnapshotSelector(session),
useSessions: bindSnapshotSelector(createSnapshotStore({
ids: [], byId: {}, current: undefined, phase: 'ready',
subagentsByParent: {}, currentAddress: undefined,
})),
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
@@ -123,8 +126,9 @@ function bench(over?: BenchOptions) {
const view = render(<InputBar {...props} />)
const textarea = view.container.querySelector('textarea')!
// aria-label (not role name): title carries the same label and would double-match.
const stopping = over?.running === true && over.subagent === undefined
const button = view.container.querySelector<HTMLButtonElement>(
`button[aria-label="${over?.running === true ? '停止生成' : '发送消息'}"]`,
`button[aria-label="${stopping ? '停止生成' : '发送消息'}"]`,
)!
return { view, textarea, button, props, sink, shell, wiring: shell, session, stop, slotCalls, menuLauncher }
}
@@ -207,6 +211,38 @@ describe('running and lock semantics (queue cut 1)', () => {
expect(stop).toHaveBeenCalledTimes(1)
})
it('running subagent primary admits a follow-up instead of exposing Stop', () => {
const { button, sink, stop } = bench({
running: true,
draft: '后续消息',
subagent: {
address: {
parentSessionId: 'parent' as SessionId,
childSessionId: SID,
mode: 'continuable',
},
parentAvailable: true,
},
})
expect(button.getAttribute('aria-label')).toBe('发送消息')
fireEvent.click(button)
expect(sink).toHaveBeenCalledWith('后续消息')
expect(stop).not.toHaveBeenCalled()
const empty = bench({
running: true,
subagent: {
address: {
parentSessionId: 'parent' as SessionId,
childSessionId: SID,
mode: 'continuable',
},
parentAvailable: true,
},
})
expect(empty.button.disabled).toBe(true)
})
it('disabled (session removed) locks the textarea and chrome', () => {
const { textarea, view } = bench({ disabled: true })
expect(textarea.disabled).toBe(true)

View File

@@ -29,7 +29,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active',
removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false,
loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
})
const props: InputBarProps = {
sessionId: SID,
@@ -37,6 +37,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
useSession: bindSnapshotSelector(session),
useSessions: bindSnapshotSelector(createSnapshotStore({
ids: [], byId: {}, current: undefined, phase: 'ready',
subagentsByParent: {}, currentAddress: undefined,
})),
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,

View File

@@ -115,7 +115,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
sessionId, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null,
promptError: null, blank: false, subagent: null, lastAgentError: null,
})
const barProps: InputBarProps = {
sessionId,
@@ -123,6 +123,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
useSession: bindSnapshotSelector(sessionStore),
useSessions: bindSnapshotSelector(createSnapshotStore({
ids: [], byId: {}, current: undefined, phase: 'ready',
subagentsByParent: {}, currentAddress: undefined,
})),
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,

View File

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

View File

@@ -170,6 +170,8 @@ describe('ReadRow keyed toolview', () => {
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd: '/w/app' } },
current: SID,
phase: 'ready',
subagentsByParent: {},
currentAddress: undefined,
})
const rowProps = (block: RunningToolCall | ToolResultNode): Parameters<typeof ReadRow>[0] => ({
@@ -249,12 +251,14 @@ describe('DetailsPanel Output section (read)', () => {
const chat = createChatStore().create()
if (selection !== null) chat.actions.select(selection)
const sessions = createSnapshotStore<SessionListState>(cwd === undefined
? { ids: [], byId: {}, current: undefined, phase: 'ready' }
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }
: {
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } },
current: SID,
phase: 'ready',
subagentsByParent: {},
currentAddress: undefined,
})
const workspaces = createSnapshotStore<WorkspaceListState>({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
@@ -282,7 +286,7 @@ describe('DetailsPanel Output section (read)', () => {
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null, ...over,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
}
}

View File

@@ -370,7 +370,10 @@ describe('DetailsPanel Output section (search)', () => {
localStorage.clear()
const chat = createChatStore().create()
if (selection !== null) chat.actions.select(selection)
const sessions = createSnapshotStore<SessionListState>({ ids: [], byId: {}, current: undefined, phase: 'ready' })
const sessions = createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'ready',
subagentsByParent: {}, currentAddress: undefined,
})
const workspaces = createSnapshotStore<WorkspaceListState>({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
@@ -397,7 +400,7 @@ describe('DetailsPanel Output section (search)', () => {
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null, ...over,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
}
}

View File

@@ -71,7 +71,7 @@ function conversationSnapshot(overrides: Partial<ConversationSnapshot> = {}): Co
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null,
promptError: null, blank: false, subagent: null, lastAgentError: null,
...overrides,
}
}
@@ -87,6 +87,8 @@ function mount(
summaryBlank?: boolean
/** Drop the session's summary row entirely (a session the list has not caught up with). */
omitSummaryRow?: boolean
/** Classify the selected child as a subagent instead of an ordinary fork. */
summaryOrigin?: 'subagent'
} = {},
) {
const root = sid('root')
@@ -94,13 +96,14 @@ function mount(
const childRow = {
id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one',
running: false, waitingApproval: false, blank: options.summaryBlank ?? false, updatedAt: 2,
...(options.summaryOrigin === undefined ? {} : { origin: options.summaryOrigin }),
}
const listed = options.omitSummaryRow !== true
const sessions = createSnapshotStore<SessionListState>({
ids: listed ? [root, SID] : [root],
byId: { [root]: rootRow, ...listed && { [SID]: childRow } },
current: SID,
phase: 'ready',
phase: 'ready', subagentsByParent: {}, currentAddress: undefined,
})
const workspaces = createSnapshotStore<WorkspaceListState>(workspaceState(workspaceRows))
const session = createSnapshotStore<ConversationSnapshot>(snapshot)
@@ -111,6 +114,7 @@ function mount(
const useInput = bindSnapshotSelector(wiring.state)
const inputActions = wiring.actions
const stop = vi.fn()
const open = vi.fn()
const slotCalls: string[] = []
let pickerOwner: unknown
const renderSlot = ((key: string, owner: object, opts?: { only?: string }) => {
@@ -139,6 +143,8 @@ function mount(
version: () => 1,
}}
bindDraftMirror={write => wiring.bindMirror(write)}
open={open}
t={t}
{...owner}
/>
)
@@ -200,7 +206,7 @@ function mount(
}
const view = render(<ConversationRoot {...props} />)
return {
view, chat, sink, retargetWorkspace, session, slotCalls,
view, chat, sink, retargetWorkspace, session, slotCalls, open,
pickerOwner: () => pickerOwner,
rerender: () => { view.rerender(<ConversationRoot {...props} />) },
}
@@ -215,10 +221,18 @@ describe('ConversationRoot resident composer', () => {
expect(b.chat.store.getSnapshot().draft).toBe('ordinary revised')
fireEvent.keyDown(box, { key: 'Enter' })
expect(b.sink).toHaveBeenCalledWith('ordinary revised')
expect(b.view.getByRole('heading', { name: 'Child', level: 1 })).toBeTruthy()
expect((b.view.getByRole('button', { name: 'Child' }) as HTMLButtonElement).disabled).toBe(true)
expect(b.view.queryByText('Root')).toBeNull()
})
it('shows hierarchy only for subagents and opens their ordinary owner', () => {
const b = mount(conversationSnapshot(), undefined, undefined, { summaryOrigin: 'subagent' })
const root = b.view.getByRole('button', { name: 'Root' })
expect((b.view.getByRole('button', { name: 'Child' }) as HTMLButtonElement).disabled).toBe(true)
fireEvent.click(root)
expect(b.open).toHaveBeenCalledWith(sid('root'))
})
it('active phase: fixed header outside the scrollport; sticky composer seat inside it', () => {
const b = mount(conversationSnapshot())
const host = b.view.container.querySelector('[data-conversation-scroll]')

View File

@@ -345,6 +345,8 @@ describe('BashRow terminal card', () => {
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0 } },
current: undefined,
phase: 'ready',
subagentsByParent: {},
currentAddress: undefined,
})
const rowProps = (block: RunningToolCall | ToolResultNode): BashRowProps => ({
@@ -421,12 +423,14 @@ describe('DetailsPanel Output section', () => {
const chat = createChatStore().create()
if (selection !== null) chat.actions.select(selection)
const sessions = createSnapshotStore<SessionListState>(cwd === undefined
? { ids: [], byId: {}, current: undefined, phase: 'ready' }
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }
: {
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } },
current: SID,
phase: 'ready',
subagentsByParent: {},
currentAddress: undefined,
})
const workspaces = createSnapshotStore<WorkspaceListState>({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
@@ -454,7 +458,7 @@ describe('DetailsPanel Output section', () => {
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null, ...over,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
}
}
@@ -605,7 +609,10 @@ describe('DetailsPanel Output section', () => {
sessionId={SID}
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
useSessions={bindSnapshotSelector(createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready' }))}
{
ids: [], byId: {}, current: undefined, phase: 'ready',
subagentsByParent: {}, currentAddress: undefined,
}))}
useWorkspaces={bindSnapshotSelector(createSnapshotStore<WorkspaceListState>({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,

View File

@@ -99,12 +99,12 @@ describe('TodoDock', () => {
expect(screen.queryByTestId('todo-panel')).toBeNull()
})
it('registers between the goal and queue entries', () => {
it('registers before the goal and queue entries', () => {
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: 10, locale: NS }, TodoDock)
expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: 0, locale: NS }, TodoDock)
})
})

View File

@@ -205,7 +205,10 @@ describe('DetailsPanel web Output section', () => {
localStorage.clear()
const chat = createChatStore().create()
if (selection !== null) chat.actions.select(selection)
const sessions = createSnapshotStore<SessionListState>({ ids: [], byId: {}, current: undefined, phase: 'ready' })
const sessions = createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'ready',
subagentsByParent: {}, currentAddress: undefined,
})
const workspaces = createSnapshotStore<WorkspaceListState>({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
@@ -232,7 +235,7 @@ describe('DetailsPanel web Output section', () => {
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null, ...over,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
}
}

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-goal/README.md
README.md: cfb54fd28044ed80e6ec05de0be057f5d4cfaf46
README.zh.md: fd999cf1c4c9695d15cfaab3e83afdf475f40448
README.md: 3da9d97c801a0a742de2601e5261c09ba193cf33
README.zh.md: c2474fc6ef8d0c990da4b4eaff79d56baf3180cf

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Goal surface plugin, browser half: the `GoalBar` strip is the first standalone card in the `conversation.input.dock` composer-context stack (order 0, before Todo and Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no domain store, refresh chain, or event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear over the `goal.*` wire domain — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the settled RPC error inline. The strip single-flights mutations synchronously because React's pending render cannot fence same-frame clicks; after a successful clear it immediately suppresses that exact goal id while the authoritative null projection catches up. Goal creation stays on the `/goal` host command; loading, absent, completed, and successfully cleared goals render nothing.
Goal surface plugin, browser half: the `GoalBar` strip is the second standalone card in the `conversation.input.dock` composer-context stack (order 10, after Todo and before Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no domain store, refresh chain, or event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear over the `goal.*` wire domain — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the settled RPC error inline. The strip single-flights mutations synchronously because React's pending render cannot fence same-frame clicks; after a successful clear it immediately suppresses that exact goal id while the authoritative null projection catches up. Goal creation stays on the `/goal` host command; loading, absent, completed, and successfully cleared goals render nothing.
The `/client` export surface is the plugin body (`apply`/`inject`), the `GoalBar`/`GoalDock` components, and the injected verb face types.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
Goal 表面插件(浏览器半件):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第张独立卡片order 0位于 Todo Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有领域 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词edit / pause / resume / clear`goal.*` 协议域——active 的 goal 提供暂停动作paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref并把结算后的 RPC 错误内联呈现。由于 React 的 pending 渲染无法拦住同一帧内的点击,横条会同步为变更建立 single-flight 防护;清除成功后,会立即抑制该 goal id 对应的目标显示,直到权威的 null 投影追上。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成和已成功清除的 goal 一律不渲染。
Goal 表面插件(浏览器半件):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第张独立卡片order 10位于 Todo 之后、Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有领域 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词edit / pause / resume / clear`goal.*` 协议域——active 的 goal 提供暂停动作paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref并把结算后的 RPC 错误内联呈现。由于 React 的 pending 渲染无法拦住同一帧内的点击,横条会同步为变更建立 single-flight 防护;清除成功后,会立即抑制该 goal id 对应的目标显示,直到权威的 null 投影追上。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成和已成功清除的 goal 一律不渲染。
`/client` 出口面为插件本体(`apply`/`inject`)、`GoalBar`/`GoalDock` 组件与注入动词面类型。

View File

@@ -1,10 +1,18 @@
/* GoalBar: the first standalone card in the composer context stack (Figma
9:939). Its 752px column matches Todo and the Queue panel. */
/* GoalBar: the second standalone card in the composer context stack (Figma
1236:32276). Its 752px column matches Todo and the Queue panel. */
.dock {
box-sizing: border-box;
width: 100%;
padding: 0 44px;
width: calc(
100% -
var(--dsh-composer-side-clearance) -
var(--dsh-composer-side-clearance) -
var(--dsh-composer-dock-inset) -
var(--dsh-composer-dock-inset) -
var(--dsh-composer-dock-inset) -
var(--dsh-composer-dock-inset)
);
margin: 0 auto;
}
.bar {

View File

@@ -75,7 +75,7 @@ export function apply(ctx: ClientContext): void {
scope.effect(() => scope.slots.register({
name: 'conversation.input.dock',
id: 'goal',
order: 0,
order: 10,
locale: NS,
inject: (sessionId): GoalBarActions => ({
onEdit: async (objective) => {

View File

@@ -97,7 +97,7 @@ describe('ui-goal browser plugin', () => {
it('registers the GoalBar dock entry with the documented id and order', async () => {
const b = bench()
await b.fiber.await()
expect(b.entry()).toMatchObject({ id: 'goal', order: 0, locale: 'goal' })
expect(b.entry()).toMatchObject({ id: 'goal', order: 10, locale: 'goal' })
expect(b.entry()?.inject).toBeTypeOf('function')
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-model/README.md
README.md: 267717c78434f7a73b1c1eebca0cc0f9d65c3642
README.zh.md: 6d6f433315336812a51b5110ceeac3eecbd9bbd4
README.md: 27fb7b936b796b956f7348fa776856180350bb56
README.zh.md: 9cc6b04ef2e7ba24fb8fc3f6d5456bf88f0652fe

View File

@@ -2,13 +2,13 @@
English | [中文](README.zh.md)
Model selection plugin, browser half: TWO entries over ONE per-session directory owned by `ModelService` (`ctx.models`). The `/model` popupSelect contribution (registered through `ctx.command`) and the composer's named `conversation.input.model` seat both load the session's advisory directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance. The compact composer trigger opens a two-level Model/Effort menu: models stay provider-grouped, while the selected exact model supplies its adapter-owned effort names, descriptions, and default. The Host-reported provider/model/reasoning target is the single fact both entries echo; `/model` applies the selected model's default effort, and the composer can then choose any advertised effort. Directory loads and selections share a generation counter so an older response never overwrites a newer one; a connection reset drops every resident projection and repulls the Host-restored target before display. Provider-local metadata failures list inline while usable groups stay selectable, and selection failures retain the prior target and directory. Directories are per-session, resolved lazily through `ctx.models.directoryFor(sessionId)`, and disposed with the session scope.
Model selection plugin, browser half: TWO entries over ONE per-session directory owned by `ModelService` (`ctx.models`). For ordinary sessions, the `/model` popupSelect contribution (registered through `ctx.command`) and the composer's named `conversation.input.model` seat both load the session's advisory directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance. The compact composer trigger opens a two-level Model/Effort menu: models stay provider-grouped, while the selected exact model supplies its adapter-owned effort names, descriptions, and default. The Host-reported provider/model/reasoning target is the single fact both entries echo; `/model` applies the selected model's default effort, and the composer can then choose any advertised effort. Directory loads and selections share a generation counter so an older response never overwrites a newer one; a connection reset drops every resident projection and repulls the Host-restored target before display. Provider-local metadata failures list inline while usable groups stay selectable, and selection failures retain the prior target and directory. Directories are per-session, resolved lazily through `ctx.models.directoryFor(sessionId)`, and disposed with the session scope. Addressed subagent sessions expose neither entry, and their directory rejects loads, selections, and reconnect refreshes, because ordinary Agent-bound model RPCs would activate persisted child history outside the direct-parent continuation seam.
The `/client` export surface is the plugin body (`apply`/`inject`), `ModelService`, `ModelDirectory` with its state shape, and the seat's injected face type.
## Model Experience
Indirectly, through the `session.selectModel` RPC both entries submit: the Host snapshots the selected provider/model/reasoning target at the next prompt-assembly boundary, so the following request uses the chosen route and effort while a running step keeps its assembled target. The selection becomes durable only when the existing request header records a request that consumes it; menu interaction adds no prompt content.
Indirectly, through the `session.selectModel` RPC available to ordinary sessions, both entries submit the provider/model/reasoning target that the Host snapshots at the next prompt-assembly boundary, so the following request uses the chosen route and effort while a running step keeps its assembled target; the selection becomes durable only when the existing request header records a request that consumes it, and menu interaction adds no prompt content.
#### KV Cache effect
@@ -16,6 +16,6 @@ Switching the route can reduce or invalidate provider-side cache reuse for subse
## Known Limitations and Deferred Work
- **No create-time selection** — both entries address an existing session's agent; there is no draft-phase model choice to fold into session creation (the seed order at the host's `targetFor` documents where such a tier would go).
- **No create-time or addressed-subagent selection** — both entries require an existing ordinary session's Agent; there is no draft-phase model choice to fold into session creation, and subagent continuation deliberately exposes no independent model-retargeting contract.
- **Directory names are presentation-only** — selection and persistence use provider/model/effort ids; a provider whose catalog or exact-model metadata lookup fails lists as an unselectable failure row until reload.
- **No arbitrary effort input** — the composer offers only the exact model's adapter-advertised levels; an adapter without reasoning metadata leaves the Effort row absent.

View File

@@ -2,13 +2,13 @@
[English](README.md) | 中文
模型选择插件(浏览器侧):**两个入口共用一份会话级目录**,由 `ModelService``ctx.models`)持有。`/model` popupSelect 贡献项(经 `ctx.command` 注册)与 composer 的具名 `conversation.input.model` 坑位都通过同一个 `ModelDirectory` 实例,经 `session.models` 加载会话的建议目录,并经 `session.selectModel` 提交。紧凑型 composer 触发器会打开两级 Model/Effort 菜单:模型仍按提供方分组,所选具体模型则提供由其适配器持有的推理强度名称、说明和默认值。Host 报告的提供方模型推理reasoning目标是两个入口共同回显的唯一事实`/model` 应用所选模型的默认推理强度composer 随后可以选择任一已公布的推理强度。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置会丢弃所有常驻目录投影,并在显示前重新拉取 Host 恢复的目标。提供方元数据获取失败会内联列出,同时可用分组仍可选择;选择失败会保留先前的目标和目录。目录按会话惰性解析(`ctx.models.directoryFor(sessionId)`),随会话作用域一并释放
模型选择插件(浏览器侧):**两个入口共用一份 per-session 目录**,由 `ModelService``ctx.models`)持有。对于普通会话,`/model` popupSelect contribution(经 `ctx.command` 注册)与 composer 的具名 `conversation.input.model` 坑位都通过同一个 `ModelDirectory` 实例,经 `session.models` 加载会话的建议目录,并经 `session.selectModel` 提交。紧凑型 composer 触发器会打开两级 Model/Effort 菜单:模型仍按提供方分组,所选确切模型则提供由其适配器持有的推理强度名称、说明和默认值。Host 报告的提供方模型推理reasoning目标是两个入口共同回显的唯一事实`/model` 应用所选模型的默认推理强度composer 随后可以选择任一已公布的推理强度。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置会丢弃所有常驻目录投影,并在显示前重新拉取 Host 恢复的目标。提供方元数据失败会内联列出,同时可用分组仍可选择;选择失败会保留先前的目标和目录。目录按会话惰性解析(`ctx.models.directoryFor(sessionId)`),随会话 scope 一并释放。已寻址 subagent 会话不公开任一入口,其目录会拒绝加载、选择与重新连接刷新,因为绑定到 agent智能体的普通模型 RPC 会在直接 parent 继续执行 seam 之外激活持久化 child 历史
`/client` 导出面为插件本体(`apply`/`inject`)、`ModelService``ModelDirectory` 及其状态形状、坑位注入面类型。
## 模型体验
间接影响,经两个入口共同提交`session.selectModel` RPCHost 在下一次提示词组装边界快照所选提供方/模型/推理强度目标,因此下一次请求采用所选路由和推理强度,而运行中的步骤保留已组装目标只有当现有请求头记录一次实际采用该选择的请求后,选择才会持久化菜单交互不会添加提示词内容。
间接影响,经仅普通会话可用`session.selectModel` RPC,两个入口都会提交提供方/模型/推理强度目标,Host 在下一次提示词组装边界对该目标进行快照,因此后续请求采用所选路由和推理强度,而运行中的步骤保留已组装目标只有当现有请求头记录一次实际采用该选择的请求后,选择才会持久化,且菜单交互不会添加提示词内容。
#### KV Cache 影响
@@ -16,6 +16,6 @@
## 已知限制与暂缓事项
- **无创建期选择**——两个入口都面向既有会话的 agent智能体没有将草稿阶段的模型选择纳入会话创建的通道host 的 `targetFor` 中的种子顺序说明了该层未来的落点)
- **目录名仅供呈现**——选择与持久化使用提供方/模型/推理强度 id目录查询或具体模型元数据查询失败的提供方以不可选失败行列出,重新加载前保持原样。
- **不能任意输入推理强度**——composer 仅提供具体模型由适配器公布的推理强度;适配器没有推理元数据时不显示 Effort 行。
- **无创建期或已寻址 subagent 选择**——两个入口都要求既有普通会话的 agent;没有可折入会话创建的 Draft 期模型选择subagent 继续执行也有意不公开独立更改模型目标的契约
- **目录名仅供呈现**——选择与持久化使用提供方/模型/推理强度 id目录查询或确切模型元数据查询失败的提供方以不可选失败行列出,重新加载前保持原样。
- **不能任意输入推理强度**——composer 仅提供确切模型由适配器公布的推理强度;适配器没有推理元数据时不显示 Effort 行。

View File

@@ -40,7 +40,8 @@ interface EffortChoice {
* @returns the trigger and, while open, the two-level menu.
*/
export function ModelSelect(
{ locked, directory, load, select, t }: ModelSelectInjected & { locked: boolean } & PropsLocale<'model'>,
{ locked, available, directory, load, select, t }:
ModelSelectInjected & { locked: boolean } & PropsLocale<'model'>,
) {
const state = useSyncExternalStore(
fn => directory.subscribe(fn),
@@ -92,7 +93,9 @@ export function ModelSelect(
const busy = state.status === 'selecting'
// Mount-time load resolves the trigger label; every open refreshes.
useEffect(() => { load() }, [load])
useEffect(() => {
if (available) load()
}, [available, load])
useEffect(() => {
if (!open) return
@@ -103,6 +106,8 @@ export function ModelSelect(
return () => { document.removeEventListener('mousedown', closeOutside) }
}, [open])
if (!available) return null
const show = (): void => {
setPane('root')
setOpen(true)

View File

@@ -39,10 +39,12 @@ export class ModelDirectory {
/**
* @param sessions - the session wire face (captured from the plugin's root connection).
* @param sessionId - the owning session.
* @param available - whether this session may use Agent-bound model RPCs.
*/
constructor(
private readonly sessions: Pick<IApiClient['sessions'], 'models' | 'selectModel'>,
private readonly sessionId: SessionId,
private readonly available: () => boolean,
) {}
/**
@@ -51,6 +53,7 @@ export class ModelDirectory {
* @returns the fresh directory value.
*/
async load(): Promise<SessionModels> {
this.assertAvailable()
const generation = ++this.generation
this.store.update((s) => { s.status = 'loading'; s.error = null })
const { result } = await this.sessions.models({ sessionId: this.sessionId })
@@ -80,6 +83,7 @@ export class ModelDirectory {
* @param target - provider, provider-owned model id, and optional adapter-owned effort.
*/
async select(target: ModelTarget): Promise<void> {
this.assertAvailable()
const generation = ++this.generation
this.store.update((s) => { s.status = 'selecting'; s.error = null })
const { result } = await this.sessions.selectModel({
@@ -116,6 +120,7 @@ export class ModelDirectory {
s.status = 'idle'
s.error = null
})
if (!this.available()) return
void this.load().catch(() => { /* the next menu open remains the explicit retry surface */ })
}
@@ -123,4 +128,10 @@ export class ModelDirectory {
dispose(): void {
this.disposed = true
}
private assertAvailable(): void {
if (!this.available()) {
throw new Error('model selection is unavailable for addressed subagent sessions')
}
}
}

View File

@@ -7,7 +7,9 @@
* so the host-reported current target is the single fact both surfaces echo
* — a switch made in either entry is what the other shows next. Failures
* ride each entry's own retry surface (popup shell error/retry; seat menu
* inline error) without forking the state.
* inline error) without forking the state. Addressed subagent sessions expose
* neither entry because those Agent-bound RPCs would activate persisted
* history outside the direct-parent continuation seam.
*/
import type { ModelTarget, SessionModels } from '@deepseek-ai/dsh-client-connection/client'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
@@ -119,14 +121,23 @@ export function apply(ctx: ClientContext): void {
ctx.inject(['command', 'models'], (scope: ClientContext) => {
const command = scope.get('command') as CommandServiceContract
const models = scope.models
const sessions = scope.sessions
scope.effect(() => command.register({
name: 'model',
description: t('command.description'),
available: () => true,
available: session => sessions.subagentAddress(session.sessionId) === undefined,
ui: {
kind: 'popupSelect',
options: async session => optionsOf(await models.directoryFor(session.sessionId).load(), t),
options: async (session) => {
if (sessions.subagentAddress(session.sessionId) !== undefined) {
throw new Error('model selection is unavailable for addressed subagent sessions')
}
return optionsOf(await models.directoryFor(session.sessionId).load(), t)
},
onSelect: async (option, session) => {
if (sessions.subagentAddress(session.sessionId) !== undefined) {
throw new Error('model selection is unavailable for addressed subagent sessions')
}
const directory = models.directoryFor(session.sessionId)
const target = targetOf(directory.store.getSnapshot(), option.id)
if (target === undefined) {
@@ -143,15 +154,22 @@ export function apply(ctx: ClientContext): void {
// conversation service's presence is the registration-safe signal.
ctx.inject(['slots', 'conversation', 'models'], (scope: ClientContext) => {
const models = scope.models
const sessions = scope.sessions
scope.effect(() => scope.slots.register({
name: 'conversation.input.model',
locale: NS,
inject: (sessionId): ModelSelectInjected => {
const directory = models.directoryFor(sessionId)
const available = sessions.subagentAddress(sessionId) === undefined
return {
available,
directory: directory.store,
load: () => { directory.load().catch(() => { /* surfaced on the store */ }) },
select: (target: ModelTarget) => directory.select(target).then(() => true, () => false),
load: () => {
if (available) directory.load().catch(() => { /* surfaced on the store */ })
},
select: (target: ModelTarget) => available
? directory.select(target).then(() => true, () => false)
: Promise.resolve(false),
}
},
}, ModelSelect), 'ui-model: composer model seat registration')

View File

@@ -68,7 +68,11 @@ export class ModelService extends Service {
const actx = sessions.scope(sessionId)
if (actx === undefined) throw new Error(`ui-model: session "${String(sessionId)}" resolved no scope`)
const connection = this.ctx.get('connection') as ConnectionHandle
const directory = new ModelDirectory(connection.api.sessions, sessionId)
const directory = new ModelDirectory(
connection.api.sessions,
sessionId,
() => sessions.subagentAddress(sessionId) === undefined,
)
live.directories.set(sessionId, directory)
actx.effect(() => () => {
directory.dispose()

View File

@@ -10,6 +10,8 @@ import type { ModelDirectoryState } from './directory.ts'
/** Injected business face of the composer model seat. */
export interface ModelSelectInjected {
/** Whether this session supports Agent-bound model inspection and selection. */
available: boolean
/** The session's shared directory store (same instance the /model popup reads). */
directory: SnapshotStore<ModelDirectoryState>
/** Refresh the advisory directory (fire-and-forget; errors land on the store). */

View File

@@ -93,7 +93,13 @@ async function bench() {
ctx.provide('conversation', {})
ctx.provide('locale', new LocaleService(ctx))
const scopes = new Map<SessionId, Context>()
ctx.provide('sessions', { scope: (id: SessionId) => scopes.get(id) })
const addressed = new Set<SessionId>()
ctx.provide('sessions', {
scope: (id: SessionId) => scopes.get(id),
subagentAddress: (id: SessionId) => addressed.has(id)
? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const }
: undefined,
})
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
await ctx.plugin(function probe() {}).await()
@@ -108,6 +114,7 @@ async function bench() {
seat: () => seats.get('conversation.input.model')!,
hostCurrent: () => current,
setHostCurrent: (target: ModelTarget) => { current = target },
address: (id: SessionId) => { addressed.add(id) },
}
}
@@ -214,4 +221,30 @@ describe('ui-model dual entry', () => {
const b = await bench()
expect(() => b.seat().inject!(sid('ghost'))).toThrow(/resolved no scope/)
})
it('withholds both model entries from addressed subagent sessions without Agent-bound RPCs', async () => {
const b = await bench()
b.mint('child')
b.address(sid('child'))
expect(b.contribution().available(projection('child'))).toBe(false)
await expect(b.contribution().ui.options(
projection('child'),
new AbortController().signal,
)).rejects.toThrow(/unavailable for addressed subagent/)
const face = b.seat().inject!(sid('child'))
expect(face.available).toBe(false)
face.load()
await expect(face.select({ provider: 'deepseek', model: 'deepseek-v4-pro' })).resolves.toBe(false)
await expect(b.ctx.models.directoryFor(sid('child')).load())
.rejects.toThrow(/unavailable for addressed subagent/)
await expect(b.ctx.models.directoryFor(sid('child')).select({
provider: 'deepseek',
model: 'deepseek-v4-pro',
})).rejects.toThrow(/unavailable for addressed subagent/)
b.ctx.emit('connection/reset')
await Promise.resolve()
expect(b.calls).toEqual({ models: 0, select: 0 })
})
})

View File

@@ -55,6 +55,7 @@ describe('ModelSelect reasoning effort', () => {
})
render(<ModelSelect
locked={false}
available
directory={directory}
load={vi.fn()}
select={select}
@@ -95,6 +96,7 @@ describe('ModelSelect reasoning effort', () => {
}))
render(<ModelSelect
locked={false}
available
directory={directory}
load={vi.fn()}
select={vi.fn().mockResolvedValue(true)}
@@ -108,4 +110,19 @@ describe('ModelSelect reasoning effort', () => {
expect(screen.getAllByRole('menuitemradio').map(item => item.textContent))
.toEqual(['Default', 'Standard'])
})
it('renders no Agent-bound control for an addressed subagent session', () => {
const load = vi.fn()
render(<ModelSelect
locked={false}
available={false}
directory={createSnapshotStore(state())}
load={load}
select={vi.fn().mockResolvedValue(false)}
t={t}
/>)
expect(screen.queryByRole('button')).toBeNull()
expect(load).not.toHaveBeenCalled()
})
})

View File

@@ -29,6 +29,7 @@ const seatOver = (dict: Record<string, string>, common: Record<string, string>):
/** Framework standard-kit stubs: the panel consumes only the locale seat. */
const kit = {
sessionId: SID,
session: undefined,
useSession: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<ConversationSnapshot>,
useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
useWorkspaces: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<WorkspaceListState>,

View File

@@ -26,6 +26,7 @@ const seatOver = (dict: Record<string, string>, common: Record<string, string>):
* the composed props type mandates delivery of the rest (framework hooks are
* plain stubs per the client testing discipline). */
const kit = {
session: undefined,
sessionId: SID,
useSession: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<ConversationSnapshot>,
useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-skill/README.md
README.md: 2cb382f53466c07b977eef4d5a1ef2804c13abea
README.zh.md: 2fc30da5e4c895027ab9dea78e9e3f86890cafc1
README.md: fc83ae47dc83e72d60f382892aa678989902d217
README.zh.md: 60f2c258acdb7e19148e05f19061e0e3f2c28ee7

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Skill reference source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}` — every session is agent-backed and the host resolves `cwd` from the session header. The host returns the intersection of model-invocable and user-invocable skills because this browser path lets a user insert a model reference rather than loading the body directly. Catalogs cache per session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry and `connection/reset` clears everything. Results filter by `startsWith(query)`; picking a candidate lands the literal `/name ` text through the slash pipeline (decision 21 plain-text reference), and the source `codec` owns the reference's two projections: `clipboardText``/name`, `serialize` → the model form `<skill>name</skill>` invoked at submit time. The RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument. The source implements no `matchSpace`/`matchEnter` hooks — skill references never enter command adjudication and ride ordinary prompts into the default sink.
Skill reference source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Ordinary-session candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}`, with the host resolving `cwd` from the session header. The host returns the intersection of model-invocable and user-invocable skills because this browser path inserts a model reference rather than loading the body directly. Catalog-addressed continuable children resolve no skill candidates locally because the existing skill RPC requires an attached session; viewing their persisted history must not activate them. Catalogs cache per ordinary session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry and `connection/reset` clears everything. Results filter by `startsWith(query)`; picking a candidate lands the literal `/name ` text through the slash pipeline (decision 21 plain-text reference), and the source `codec` owns the reference's two projections: `clipboardText``/name`, `serialize` → the model form `<skill>name</skill>` invoked at submit time. The RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument. The source implements no `matchSpace`/`matchEnter` hooks — skill references never enter command adjudication and ride ordinary prompts into the default sink.
A failed `skill.list` throws from `candidates`, which the slash shell logs and folds into a silent menu-group drop — the menu shows only pending/ready states.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
skill技能引用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。候选来自 `skill.list` RPC以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址——每个会话始终由 agent智能体支撑host 从会话 header 解析 `cwd`。宿主返回模型可调用与用户可调用 skill 的交集,因为该浏览器路径让用户插入模型引用,而不是直接加载正文。目录按会话缓存,拉取走 single-flightscope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤pick 一个候选会把字面文本 `/name ` 经 slash 管线落进草稿(决策 21 的纯文本引用source 的 `codec` 拥有该引用的两种投影:`clipboardText``/name``serialize` → 提交时生成的模型形式 `<skill>name</skill>`。RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务。source 不实现 `matchSpace``matchEnter` 钩子——skill 引用永不进入命令裁决,随普通提示词落入 default sink。
skill技能引用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`普通会话的候选来自 `skill.list` RPC以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址host 从会话 header 解析 `cwd`。宿主返回模型可调用与用户可调用 skill 的交集,因为该浏览器路径插入的是模型引用,而不是直接加载正文。由目录寻址的可继续子代理在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flightscope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤pick 一个候选会把字面文本 `/name ` 经 slash 管线落进草稿(决策 21 的纯文本引用source 的 `codec` 拥有该引用的两种投影:`clipboardText``/name``serialize` → 提交时生成的模型形式 `<skill>name</skill>`。RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务。source 不实现 `matchSpace``matchEnter` 钩子——skill 引用永不进入命令裁决,随普通提示词落入 default sink。
`skill.list` 失败时 `candidates` 抛出异常slash 壳层记录日志并折叠为静默的菜单组丢弃——菜单只显示 pendingready 状态。

View File

@@ -21,7 +21,7 @@
* with an aborted signal just returns early.
*/
import type { ConnectionHandle, SessionId, SkillEntry } from '@deepseek-ai/dsh-client-connection/client'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientContext, ISessions } from '@deepseek-ai/dsh-client-runtime/client'
import type { SlashServiceContract, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
/** One session's catalog fetch: the shared promise plus its own abort handle. */
@@ -32,8 +32,8 @@ interface CatalogFetch {
settled?: readonly SkillEntry[]
}
/** Required services: the slash registry + the wire face the source closes over. */
export const inject = ['slash', 'connection']
/** Required services: slash registry, routed sessions, and the wire face. */
export const inject = ['slash', 'connection', 'sessions']
/**
* Client plugin body: register the '/' skill source over the root wire face.
@@ -41,6 +41,7 @@ export const inject = ['slash', 'connection']
*/
export function apply(ctx: ClientContext): void {
const skills = (ctx.get('connection') as ConnectionHandle).api.skills
const sessions = ctx.get('sessions') as ISessions
// Session-keyed catalog cache; single-flight per key. Plugin-closure state:
// the fiber effect below is its teardown boundary.
const fetches = new Map<SessionId, CatalogFetch>()
@@ -61,6 +62,7 @@ export function apply(ctx: ClientContext): void {
}
const fetchCatalog = (sessionId: SessionId): Promise<readonly SkillEntry[]> => {
if (sessions.subagentAddress(sessionId) !== undefined) return Promise.resolve([])
const existing = fetches.get(sessionId)
if (existing !== undefined) return existing.promise
const abort = new AbortController()

View File

@@ -24,11 +24,16 @@ type ListResult =
type ListFn = (payload: object, signal?: AbortSignal) => Promise<{ result: ListResult }>
/** Boot the plugin over fake slash/connection faces; returns the captured source and its ctx. */
async function bench(list: ListFn) {
async function bench(list: ListFn, addressed?: SessionId) {
const ctx = new Context()
let captured: SlashSource | undefined
ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } })
ctx.provide('connection', { api: { skills: { list } } })
ctx.provide('sessions', {
subagentAddress: (id: SessionId) => id === addressed
? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const }
: undefined,
})
await ctx.plugin({ inject: [...inject], apply }).await()
return { ctx, source: captured! }
}
@@ -60,7 +65,7 @@ const req = (query: string, signal?: AbortSignal) =>
describe('apply', () => {
it('declares the services it binds', () => {
expect(inject).toEqual(['slash', 'connection'])
expect(inject).toEqual(['slash', 'connection', 'sessions'])
})
it('registers the "/" skill source; disposal frees the name (HMR safety)', async () => {
@@ -106,6 +111,14 @@ describe('candidates: sessionId addressing', () => {
await expect(source.candidates(proj('s1'), req('co')))
.rejects.toThrow('skill.list failed: internal: boom')
})
it('does not fetch Agent-bound skills for an addressed child', async () => {
const { list, payloads } = countingList()
const { source } = await bench(list, sid('child'))
await expect(source.candidates(proj('child'), req(''))).resolves.toEqual([])
source.warm!(proj('child'))
expect(payloads).toEqual([])
})
})
describe('catalog cache', () => {

View File

@@ -11,11 +11,10 @@
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
/**
* The provider-facing projection of one client session. Client sessions are
* always agent-backed — the host births Session+Agent+cwd together and the
* client only creates scopes for materialized sessions — so the projection
* carries the stable session identity alone: sources address every RPC by
* `sessionId` with no capability discrimination.
* The provider-facing projection of one client session. It carries stable
* identity alone; a source that calls Agent-bound RPCs must consult its own
* service's capability state because an addressed persisted subagent may
* have a client scope without a live Host Agent.
*/
export interface ClientSessionContext {
readonly sessionId: SessionId

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-subagent/README.md
README.md: 7a70add139eae7bc507469b4fe7170359efdec31
README.zh.md: 4ff79780fd33a47a0a45695ab9feda15cd1763cd
README.md: f6b3fa2e9cdf1479a739e0b4eab15a5423e878e4
README.zh.md: fdfba385e9188cd42bd973b6f32bc01fe8d004f2

View File

@@ -2,11 +2,15 @@
English | [中文](README.zh.md)
Subagent reference source, browser half: registers the `@`-trigger `subagent` source into `ctx.slash`. Candidates are zero-RPC — filtered from the root `ctx.sessions.list` snapshot captured at registration (children of the per-call projection's session: `parentId` matches, `running`, `displayTitle` contains the query); picking a candidate lands the literal `@label ` text through the slash pipeline (decision 21 plain-text reference), and the source `codec` projects both faces as `@label` — the model serialization stays the raw label until the `@` consumption feature defines a model representation. The source implements no `matchSpace`/`matchEnter` hooks — subagent references never enter command adjudication and ride ordinary prompts into the default sink.
Web subagent feature owner: contributes the lazily expandable catalog tree to `conversation.session.header.actions`, reason-specific read-only replacements to the conversation composer chain, and the existing `@` reference source to `ctx.slash`.
A session with no running children is simply candidate-less. This phase ships "menu + reference text" only; what consuming an `@label` means (steering the child, resuming a disposed one) is future business work.
The header action reads `subagentsByParent` and session summaries through the standard `useSessions` hook. After a non-empty direct catalog arrives, its trigger counts the complete subagent-only descendant lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. The compact tree remains direct-catalog authoritative: continuable and one-shot rows display mode plus `running`/`inactive` activity, an optional log-backed title, and session-summary activity time; an unlabeled one-shot row falls back to its session id, while corrupt, unsupported, or unavailable rows remain readable but disabled. Each healthy row's `hasChildren` hint determines disclosure before interaction, so known leaves never show an arrow; expanding a branch immediately reserves one disabled loading row per known direct descendant, then lazily replaces them with that child's authoritative catalog. Every visible branch is reported to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. Selecting any depth calls `SessionsService.openSubagent()` with the row's exact `{parentSessionId, childSessionId, mode}` address. Component-local state owns tree visibility, expanded branches, and keyboard focus. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree; closing returns focus to the trigger. Styling uses tokens only.
The `/client` export surface is the plugin body (`apply`/`inject`) only; the source object is internal to the registration effect.
A one-shot child always elects a read-only composer that identifies the transcript as a completed execution record. A continuable child does so only when its exact parent is unavailable, with copy explaining the recovery path. A continuable child with a live parent keeps the ordinary input chrome, whose Session routes through `subagent.prompt`; running input remains Send because every follow-up joins the child's FIFO inbox, and addressed sessions never expose Stop. This package never receives host context or calls a model-facing tool. The catalog and composer behavior are specified by the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md).
Subagent-origin Session rows are omitted from the ordinary sidebar, so the parent header catalog is their navigation entry point. Ordinary forks remain in the sidebar.
The `@` source remains deliberately separate and inert. Candidates are zero-RPC running children from `ctx.sessions.list`; picking one inserts literal `@label ` text, and the codec projects `@label`. It has no command-adjudication hooks and does not resolve labels into continuation addresses.
## Model Experience
@@ -14,18 +18,17 @@ The `/client` export surface is the plugin body (`apply`/`inject`) only; the sou
#### What the model sees
A picked candidate lands the literal `@label` (the child session's display title) in the draft; the text reaches the model verbatim inside the ordinary user message (`session.prompt`), with no dedicated content block, prompt section, or host-side resolution. No consumption semantics exist yet: the model sees plain text and interprets it unaided.
Only the legacy `@` reference source affects model input: a picked candidate reaches the ordinary user message as literal `@label`, without a dedicated block or host-side resolution. Catalog browsing, child navigation, and persisted transcript viewing add no prompt section; accepted continuation content becomes a normal FIFO user message through the host subagent adapter.
#### Token effect
Conditional and tiny: only a pick (or hand-typing the same text) adds the label's characters to that one user message. Menu browsing adds zero model tokens (candidates never leave the browser).
Conditional and append-only: the literal `@label` or a human follow-up adds tokens only to its new user message. Catalog and transcript operations add zero model tokens.
#### KV Cache effect
Append-only: the reference is part of a new user message appended after the reusable history prefix. This package never edits earlier request tokens.
Append-only. This package never edits earlier request tokens.
## Known Limitations and Deferred Work
- **`@` consumption semantics are unbuilt** — the reference is inert text; wiring it to steer/message the named child (and whether resuming a disposed child is allowed) awaits its own design decision in the ledger.
- **Candidates are running children only** — completed or disposed subagents never appear, and the roster is the scoped session's direct children (no grandchildren, no cross-session agents).
- **Labels are display titles, not stable ids** — two children sharing a display title produce indistinguishable references, and a title change orphans previously inserted text. Acceptable while references are inert; a consumption feature must bind to session ids.
- **The catalog has coarse activity only** — it cannot show durable outcome, elapsed time, Activation identity, or an authority-safe cancel button.
- **`@` references remain display-title text** — duplicate or renamed labels are ambiguous, so they intentionally do not acquire continuation semantics.

View File

@@ -2,11 +2,15 @@
[English](README.md) | 中文
subagent 引用 source 的浏览器半侧:把 `@` 触发的 `subagent` source 注册进 `ctx.slash`。候选零 RPC——从注册时捕获的根 `ctx.sessions.list` 快照过滤(每次调用的投影所指会话的子会话:`parentId` 匹配、`running``displayTitle` 包含 querypick 一个候选会把字面文本 `@label ` 经 slash 管线落进草稿(决策 21 的纯文本引用source 的 `codec` 把两种投影都产出为 `@label`——在 `@` 消费功能定义模型表示之前,模型序列化保持原始 label。source 不实现 `matchSpace``matchEnter` 钩子——subagent 引用永不进入命令裁决,随普通提示词落入 default sink
Web subagent 功能 owner`conversation.session.header.actions` 贡献可懒加载展开的目录树,向会话编辑器链贡献按原因区分的只读替代呈现,并保留注册到 `ctx.slash` 的既有 `@` 引用 source
没有运行中子会话的会话就是没有候选。本阶段只交付「菜单 + 引用文本」;消费一个 `@label` 意味着什么(对子会话做 steering中途引导、恢复已 dispose资源释放的子会话是未来的业务工作
页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空直接目录到达后,其触发器会统计仅含 subagent 的完整后代谱系,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。紧凑树仍以直接目录为权威依据:可继续和 one-shot 行会显示 mode、`running``inactive` 活动状态、由日志支撑的可选 title 与会话摘要中的活动时间;没有 label 的 one-shot 行会回退到其会话 id而损坏、不受支持或不可用的行仍保持可读但禁用。每个健康行的 `hasChildren` 提示会在交互前决定是否显示展开控件,因此已知叶子节点从不显示箭头;展开分支时,会立即为每个已知直接后代预留一行禁用的加载行,随后再用该 child 的权威目录懒加载结果替换这些占位行。每个可见分支都会上报给运行时,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionsService.openSubagent()`。组件局部状态负责树的可见性、已展开分支与键盘焦点。ArrowRightArrowLeft 展开和折叠分支ArrowUpArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token
`/client` 的导出内容只有插件主体(`apply``inject`source 对象是注册 effect 的内部实现
one-shot child 始终选用只读编辑器,并将 transcript文本记录说明为已完成的执行记录。可继续 child 仅在其确切 parent 不可用时选用只读编辑器,并以文案说明恢复路径。确切 parent 存活时,可继续 child 保留普通输入 chrome其 Session 会通过 `subagent.prompt` 路由child 运行期间,输入操作仍为 Send因为每条后续消息都会进入 child 的 FIFO inbox且已寻址会话绝不公开 Stop。本包绝不接收宿主 context也不调用面向模型的工具。目录与编辑器行为由 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md)规定
普通侧边栏会省略带 subagent origin 的 Session 行,因此 parent 页头目录是它们的导航入口。普通 fork 仍保留在侧边栏中。
`@` source 仍然刻意保持独立且惰性。候选是从 `ctx.sessions.list` 零 RPC 得到的运行中 childpick 会插入字面文本 `@label `codec 投影为 `@label`。它不参与命令裁决,也不会把 label 解析成继续执行地址。
## 模型体验
@@ -14,18 +18,17 @@ subagent 引用 source 的浏览器半侧:把 `@` 触发的 `subagent` source
#### 模型看到的内容
pick 的候选会把字面文本 `@label`(子会话的显示标题)落进草稿;该文本原样进入普通用户消息(`session.prompt`)到达模型,没有专用内容块、提示词 section 或 host 侧解析。目前不存在任何消费语义:模型看到的是纯文本,只能自行解读
只有旧有 `@` 引用 source 会影响模型输入:pick 的候选字面文本 `@label` 进入普通用户消息,没有专用内容块或宿主侧解析。浏览目录、导航 child 与查看持久化 transcript 都不会添加提示词 section获准进入的继续交互内容会经宿主 subagent 适配器成为普通 FIFO 用户消息
#### Token 影响
有条件且极小:只有 pick或手动键入相同文本会把 label 的字符加进那一条用户消息。浏览菜单增加零模型 token候选永不离开浏览器
有条件且仅追加:字面 `@label` 或用户后续消息只会向对应的新用户消息增加 token。目录与 transcript 操作增加零模型 token
#### KV Cache 影响
仅追加:引用是追加在可复用历史前缀之后的新用户消息的一部分。该包绝不改写早的请求 token。
仅追加。本包绝不改写早的请求 token。
## 已知限制与暂缓事项
- **`@` 消费语义尚未构建**:引用只是不具消费语义的纯文本;将其接入对指名子会话进行 steering发送消息的机制以及是否允许恢复已 dispose 的子会话),仍有待台账中的专门设计决策
- **候选只有运行中的子会话**:已完成或已 dispose 的 subagent 永不出现roster 只含 scope 所指会话的直接子会话,不含孙辈,也不含跨会话 agent智能体
- **label 是显示标题,不是稳定 id**:两个子会话共用一个显示标题时,产生的引用无法区分;标题变更会使先前插入的文本失去指向。引用仍是不具消费语义的纯文本时尚可接受;消费功能必须绑定到会话 id。
- **目录只有粗粒度活动状态**它不能显示持久化结果、耗时、Activation 身份或具备安全授权的取消按钮
- **`@` 引用仍是显示标题文本**:重复或改名后的 label 会有歧义,因此它们刻意不获得继续执行语义。

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-ui-subagent",
"description": "Subagent reference source: '@' menu candidates from the session snapshot (zero RPC), inserts @label references",
"description": "Subagent conversation catalog, continuation routing UI, and '@' reference source",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -25,6 +25,8 @@
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-ui-primitives",
"@deepseek-ai/dsh-client-ui-slash"
],
"platform": "web"
@@ -34,8 +36,13 @@
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"dependencies": {
"react": "^18.2.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slash": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
@@ -43,9 +50,12 @@
},
"devDependencies": {
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7"
},
"files": [

View File

@@ -0,0 +1,261 @@
.root {
position: relative;
}
.trigger {
display: inline-flex;
align-items: center;
gap: 3px;
min-height: 28px;
padding: 3px 2px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 18px;
cursor: pointer;
}
.count {
margin: 0 5px;
}
.activitySlot {
display: inline-flex;
flex: none;
width: 10px;
height: 10px;
}
.trigger:hover,
.trigger:focus-visible {
color: var(--dsw-alias-label-secondary);
}
.trigger svg {
transition: transform 120ms ease;
}
.triggerOpen {
transform: rotate(180deg);
}
.menu {
position: absolute;
top: calc(100% + 5px);
left: 0;
z-index: 100;
box-sizing: border-box;
display: flex;
flex-direction: column;
width: 336px;
max-width: min(400px, calc(100vw - 32px));
max-height: min(560px, calc(100vh - 140px));
padding: 4px;
overflow: auto;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 12px;
background: var(--dsw-specific-menu);
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
box-shadow: var(--dsw-shadow-lv3);
}
.node {
position: relative;
min-width: 0;
}
.menu > .node {
margin-left: -8px;
}
.row {
position: relative;
display: flex;
align-items: flex-start;
gap: 8px;
box-sizing: border-box;
width: 100%;
min-height: 50px;
padding: 7px 8px 7px 11px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--dsw-alias-label-primary);
font-size: 13px;
line-height: 18px;
text-align: left;
cursor: pointer;
outline: none;
}
.row:hover > .clickarea,
.row:focus-visible > .clickarea {
background: var(--dsw-alias-interactive-bg-hover);
}
.clickarea {
box-sizing: border-box;
display: flex;
flex: 1;
align-self: stretch;
align-items: flex-start;
gap: 8px;
min-width: 0;
margin: -7px -8px -7px;
padding: 7px 8px;
border-radius: 8px;
}
.row > :global([data-state]),
.clickarea > :global([data-state]) {
margin-top: 4px;
}
.disabled {
color: var(--dsw-alias-label-dimmed);
cursor: not-allowed;
}
.disabled:hover {
background: transparent;
}
.loadingRow {
cursor: default;
}
.disclosure,
.disclosureSpace {
flex: none;
width: 14px;
height: 18px;
}
.disclosure {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
transition: transform 120ms ease;
}
.disclosure:hover {
color: var(--dsw-alias-label-primary);
}
.disclosureOpen {
transform: rotate(90deg);
}
.content {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.label,
.summary {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.label {
color: inherit;
font-weight: 400;
}
.summary,
.time {
color: var(--dsw-alias-label-tertiary);
font-size: 11px;
line-height: 16px;
}
.time {
flex: none;
margin-top: 16px;
}
.children {
position: relative;
margin-left: 18px;
padding-left: 4px;
}
.children::before,
.children > .node::before {
content: '';
position: absolute;
left: 0;
border-left: 1px solid var(--dsw-alias-border-l2);
}
.children::before {
top: -26px;
height: 26px;
}
.children[aria-busy='true']::before {
content: none;
}
.children > .node::before {
top: 0;
bottom: 0;
left: -4px;
}
.children > .node:last-child::before {
bottom: auto;
height: 17px;
}
.children > .node > .row::before {
content: '';
position: absolute;
top: 16px;
left: -4px;
width: 14px;
border-top: 1px solid var(--dsw-alias-border-l2);
}
.notice,
.error {
padding: 10px 12px;
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 18px;
}
.error {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: var(--dsw-alias-state-error-primary);
}
.refresh {
display: inline-flex;
flex: none;
align-items: center;
gap: 4px;
padding: 4px 6px;
border: 0;
border-radius: 6px;
background: transparent;
color: inherit;
cursor: pointer;
}
.refresh:hover {
background: var(--dsw-alias-interactive-bg-hover);
}

View File

@@ -0,0 +1,456 @@
import {
useEffect, useRef, useState, type KeyboardEvent, type MouseEvent,
} from 'react'
import type {
SessionId, SessionListState, SessionSummary, SubagentAddress, SubagentCatalogSnapshot,
} from '@deepseek-ai/dsh-client-runtime/client'
import {
IconChevronDownOutline14, IconChevronRightOutline14, IconRefreshOutline14, StateDot,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import css from './SubagentCatalogAction.module.css'
type CatalogEntry = SubagentCatalogSnapshot['entries'][number]
type Catalogs = SessionListState['subagentsByParent']
/** Business actions supplied by the slot registration. */
export interface SubagentCatalogInjected {
openChild: (address: SubagentAddress) => void
refresh: (parentSessionId: SessionId) => void
setCatalogOpen: (parentSessionId: SessionId, open: boolean) => void
}
/** Full props for the session-header catalog action. */
export type SubagentCatalogActionProps =
PropsRuntime<'conversation.session.header.actions'> & SubagentCatalogInjected
interface CatalogRowsProps {
parentSessionId: SessionId
catalog: SubagentCatalogSnapshot
catalogs: Catalogs
summaries: Readonly<Record<SessionId, SessionSummary>>
expanded: ReadonlySet<SessionId>
level: number
now: number
openChild: (address: SubagentAddress) => void
refresh: (parentSessionId: SessionId) => void
toggleBranch: (childSessionId: SessionId) => void
closeCatalog: () => void
}
function diagnosticReason(entry: Extract<CatalogEntry, { kind: 'diagnostic' }>): string {
switch (entry.reason) {
case 'corrupt': return '会话记录损坏'
case 'unsupported': return '子代理记录版本不受支持'
case 'unavailable': return '会话记录暂不可用'
}
}
function treeItems(root: HTMLDivElement | null): HTMLElement[] {
return root === null
? []
: Array.from(root.querySelectorAll<HTMLElement>('[role="treeitem"]:not([aria-disabled="true"])'))
}
/** Compact trailing activity time for a catalog row. */
function relativeTime(updatedAt: number | undefined, now: number): string | undefined {
if (updatedAt === undefined) return undefined
const minute = 60_000
const hour = 60 * minute
const day = 24 * hour
const diff = Math.max(0, now - updatedAt)
if (diff < minute) return '刚刚'
if (diff < hour) return `${Math.floor(diff / minute)}分钟`
if (diff < day) return `${Math.floor(diff / hour)}小时`
if (diff < 30 * day) return `${Math.floor(diff / day)}`
if (diff < 365 * day) return `${Math.floor(diff / (30 * day))}个月`
return `${Math.floor(diff / (365 * day))}`
}
/** Aggregate the complete subagent-only descendant subtree from flat summaries. */
function summarizeDescendants(
sessionId: SessionId,
summaries: Readonly<Record<SessionId, SessionSummary>>,
): { count: number; running: boolean } {
let count = 0
let running = false
for (const summary of Object.values(summaries)) {
if (summary.origin !== 'subagent') continue
const seen = new Set<SessionId>()
let current: SessionSummary | undefined = summary
while (current?.origin === 'subagent' && current.parentId !== undefined
&& !seen.has(current.id)) {
seen.add(current.id)
if (current.parentId === sessionId) {
count += 1
running ||= summary.running
break
}
current = summaries[current.parentId]
}
}
return { count, running }
}
/** Render the known direct-child shape while its authoritative catalog hydrates. */
function CatalogLoadingRows({
parentSessionId,
summaries,
level,
}: {
parentSessionId: SessionId
summaries: Readonly<Record<SessionId, SessionSummary>>
level: number
}) {
const children = Object.values(summaries).filter(summary => (
summary.origin === 'subagent' && summary.parentId === parentSessionId
))
if (children.length === 0) return <div className={css.notice}></div>
return children.map(summary => (
<div key={summary.id} className={css.node}>
<div
role="treeitem"
aria-disabled="true"
aria-level={level}
aria-label="正在加载子代理"
className={`${css.row} ${css.disabled} ${css.loadingRow}`}
>
<span className={css.disclosureSpace} />
<StateDot state={summary.running ? 'ongoing' : 'done'} />
<span className={css.content}>
<span className={css.label}></span>
</span>
</div>
</div>
))
}
/** Render one catalog level and recurse only through explicitly expanded rows. */
function CatalogRows({
parentSessionId, catalog, catalogs, summaries, expanded, level, now,
openChild, refresh, toggleBranch, closeCatalog,
}: CatalogRowsProps) {
const emptyLoading = catalog.state === 'loading' && catalog.entries.length === 0
return (
<>
{emptyLoading && (
<CatalogLoadingRows
parentSessionId={parentSessionId}
summaries={summaries}
level={level}
/>
)}
{catalog.state === 'error' && (
<div className={css.error}>
<span>{catalog.error?.message ?? '无法加载子代理'}</span>
<button
type="button"
className={css.refresh}
onClick={() => { refresh(parentSessionId) }}
>
<IconRefreshOutline14 />
</button>
</div>
)}
{catalog.entries.map((entry) => {
if (entry.kind === 'diagnostic') {
const reason = diagnosticReason(entry)
return (
<div key={entry.id} className={css.node}>
<div
role="treeitem"
aria-disabled="true"
aria-level={level}
aria-label={`${entry.id} ${reason}`}
className={`${css.row} ${css.disabled}`}
title={reason}
>
<span className={css.disclosureSpace} />
<StateDot state="error" />
<span className={css.content}>
<span className={css.label}>{entry.id}</span>
<span className={css.summary}>{reason}</span>
</span>
</div>
</div>
)
}
const childCatalog = catalogs[entry.id]
const isExpanded = expanded.has(entry.id)
const knownLeaf = !entry.hasChildren
const childLoading = childCatalog === undefined
|| (childCatalog.state === 'loading' && childCatalog.entries.length === 0)
const summary = summaries[entry.id]
const label = entry.label ?? entry.id
const mode = entry.mode === 'one-shot' ? '一次性' : '可继续'
const activity = entry.activity === 'running' ? '正在运行' : '当前未运行'
const secondary = [summary?.title, mode, activity]
.filter(value => value !== undefined)
.join(' · ')
const time = relativeTime(summary?.updatedAt, now)
const open = (): void => {
openChild({ parentSessionId, childSessionId: entry.id, mode: entry.mode })
closeCatalog()
}
const handleKey = (event: KeyboardEvent<HTMLDivElement>): void => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
event.stopPropagation()
open()
} else if (
(event.key === 'ArrowRight' && !knownLeaf && !isExpanded)
|| (event.key === 'ArrowLeft' && isExpanded)
) {
event.preventDefault()
event.stopPropagation()
toggleBranch(entry.id)
}
}
const toggle = (event: MouseEvent<HTMLButtonElement>): void => {
event.preventDefault()
event.stopPropagation()
toggleBranch(entry.id)
}
return (
<div key={entry.id} className={css.node}>
<div
role="treeitem"
tabIndex={0}
aria-level={level}
aria-label={[label, secondary, time].filter(value => value !== undefined).join(' ')}
{...knownLeaf ? {} : { 'aria-expanded': isExpanded }}
className={css.row}
onClick={open}
onKeyDown={handleKey}
>
{knownLeaf
? <span className={css.disclosureSpace} />
: (
<button
type="button"
tabIndex={-1}
className={`${css.disclosure} ${isExpanded ? css.disclosureOpen : ''}`}
aria-label={`${isExpanded ? '收起' : '展开'} ${label} 的下级子代理`}
onClick={toggle}
>
<IconChevronRightOutline14 />
</button>
)}
<div className={css.clickarea}>
<StateDot state={entry.activity === 'running' ? 'ongoing' : 'done'} />
<span className={css.content}>
<span className={css.label}>{label}</span>
<span className={css.summary}>{secondary}</span>
</span>
{time !== undefined && <span className={css.time}>{time}</span>}
</div>
</div>
{isExpanded && !knownLeaf && (
<div
role="group"
className={css.children}
aria-busy={childLoading || undefined}
>
{childCatalog === undefined
? (
<CatalogLoadingRows
parentSessionId={entry.id}
summaries={summaries}
level={level + 1}
/>
)
: (
<CatalogRows
parentSessionId={entry.id}
catalog={childCatalog}
catalogs={catalogs}
summaries={summaries}
expanded={expanded}
level={level + 1}
now={now}
openChild={openChild}
refresh={refresh}
toggleBranch={toggleBranch}
closeCatalog={closeCatalog}
/>
)}
</div>
)}
</div>
)
})}
</>
)
}
/**
* Render the current session's direct catalog and lazily expanded descendants.
* @param props - session standard props plus catalog navigation actions.
* @returns The action only after a non-empty catalog arrives.
*/
export function SubagentCatalogAction({
sessionId, useSessions, openChild, refresh, setCatalogOpen,
}: SubagentCatalogActionProps) {
const catalogs = useSessions(state => state.subagentsByParent)
const summaries = useSessions(state => state.byId)
const catalog = catalogs[sessionId]
const [open, setOpen] = useState(false)
const [expanded, setExpanded] = useState<ReadonlySet<SessionId>>(() => new Set())
const rootRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const observedCatalogs = useRef(new Set<SessionId>())
const setCatalogOpenRef = useRef(setCatalogOpen)
setCatalogOpenRef.current = setCatalogOpen
const healthy = catalog?.entries.filter(entry => entry.kind === 'child') ?? []
const descendants = summarizeDescendants(sessionId, summaries)
// The catalog can arrive before the session-list baseline; never undercount
// the already-visible direct rows during that short bootstrap window.
const descendantCount = Math.max(healthy.length, descendants.count)
const observeCatalog = (parentSessionId: SessionId, next: boolean): void => {
if (next) observedCatalogs.current.add(parentSessionId)
else observedCatalogs.current.delete(parentSessionId)
setCatalogOpen(parentSessionId, next)
}
const closeAllCatalogs = (): void => {
for (const parentSessionId of observedCatalogs.current) {
setCatalogOpen(parentSessionId, false)
}
observedCatalogs.current.clear()
setExpanded(new Set())
}
const changeOpen = (next: boolean, restoreFocus = false): void => {
setOpen(next)
if (next) observeCatalog(sessionId, true)
else closeAllCatalogs()
if (restoreFocus) queueMicrotask(() => { triggerRef.current?.focus() })
}
const closeBranch = (root: SessionId): void => {
const closing = new Set<SessionId>()
const visit = (parentSessionId: SessionId): void => {
if (closing.has(parentSessionId) || !expanded.has(parentSessionId)) return
closing.add(parentSessionId)
const branch = catalogs[parentSessionId]
for (const entry of branch?.entries ?? []) {
if (entry.kind === 'child') visit(entry.id)
}
}
visit(root)
for (const parentSessionId of closing) observeCatalog(parentSessionId, false)
setExpanded(current => new Set([...current].filter(id => !closing.has(id))))
}
const toggleBranch = (childSessionId: SessionId): void => {
if (expanded.has(childSessionId)) {
closeBranch(childSessionId)
return
}
setExpanded(current => new Set(current).add(childSessionId))
observeCatalog(childSessionId, true)
}
useEffect(() => {
if (!open) return
const closeOutside = (event: PointerEvent): void => {
if (event.target instanceof Node && !rootRef.current?.contains(event.target)) {
changeOpen(false)
}
}
document.addEventListener('pointerdown', closeOutside)
return () => { document.removeEventListener('pointerdown', closeOutside) }
}, [open])
useEffect(() => () => {
for (const parentSessionId of observedCatalogs.current) {
setCatalogOpenRef.current(parentSessionId, false)
}
observedCatalogs.current.clear()
}, [])
const visible = catalog !== undefined && (catalog.state !== 'ready' || catalog.entries.length > 0)
useEffect(() => {
if (visible || !open) return
setOpen(false)
closeAllCatalogs()
}, [visible, open])
if (!visible) return null
const focusAt = (index: number): void => {
const items = treeItems(rootRef.current)
if (items.length === 0) return
items[(index + items.length) % items.length]?.focus()
}
const navigate = (event: KeyboardEvent<HTMLDivElement>): void => {
const items = treeItems(rootRef.current)
const index = items.indexOf(document.activeElement as HTMLElement)
if (event.key === 'Escape') {
event.preventDefault()
changeOpen(false, true)
} else if (event.key === 'Home') {
event.preventDefault()
focusAt(0)
} else if (event.key === 'End') {
event.preventDefault()
focusAt(items.length - 1)
} else if (event.key === 'ArrowDown') {
event.preventDefault()
focusAt(index + 1)
} else if (event.key === 'ArrowUp') {
event.preventDefault()
focusAt(index < 0 ? items.length - 1 : index - 1)
}
}
return (
<div className={css.root} ref={rootRef} onKeyDown={navigate}>
<button
ref={triggerRef}
type="button"
className={css.trigger}
aria-haspopup="tree"
aria-expanded={open}
aria-label={`${descendantCount} 个子代理${descendants.running ? ',正在运行' : ''}`}
onClick={() => { changeOpen(!open) }}
onKeyDown={(event) => {
if (event.key !== 'ArrowDown') return
event.preventDefault()
if (!open) changeOpen(true)
queueMicrotask(() => { focusAt(0) })
}}
>
<span className={css.activitySlot}>
{descendants.running && <StateDot state="ongoing" />}
</span>
<span className={css.count}>{descendantCount} </span>
<IconChevronDownOutline14 className={open ? css.triggerOpen : undefined} />
</button>
{open && (
<div className={css.menu} role="tree" aria-label="子代理会话">
<CatalogRows
parentSessionId={sessionId}
catalog={catalog}
catalogs={catalogs}
summaries={summaries}
expanded={expanded}
level={1}
now={Date.now()}
openChild={openChild}
refresh={refresh}
toggleBranch={toggleBranch}
closeCatalog={() => { changeOpen(false) }}
/>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,20 @@
.frame {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin: 0 24px 20px;
min-height: 54px;
padding: 10px 16px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 14px;
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-tertiary);
font-size: 13px;
line-height: 20px;
}
.frame strong {
color: var(--dsw-alias-label-primary);
font-weight: 510;
}

View File

@@ -0,0 +1,32 @@
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import css from './SubagentReadOnlyComposer.module.css'
/** Why a catalog-addressed conversation cannot accept human input. */
export interface SubagentReadOnlyMatch {
reason: 'one-shot' | 'parent-unavailable'
}
/** Full chain props after the read-only subagent selector accepts the owner currency. */
export type SubagentReadOnlyComposerProps =
PropsRuntime<'conversation.composer'> & { matched: SubagentReadOnlyMatch }
/**
* Explain why the normal composer is unavailable for an addressed child.
* @param props - selector-owned read-only reason plus standard slot props.
* @returns A read-only composer replacement.
*/
export function SubagentReadOnlyComposer({
matched,
}: Pick<SubagentReadOnlyComposerProps, 'matched'>) {
const oneShot = matched.reason === 'one-shot'
return (
<div className={css.frame} role="status">
<strong>{oneShot ? '一次性子代理记录' : '此子代理暂时只读'}</strong>
<span>
{oneShot
? '一次性任务不支持后续消息,可在这里查看完整执行记录。'
: '父会话当前不在线,重新打开父会话后即可继续发送消息。'}
</span>
</div>
)
}

View File

@@ -9,18 +9,40 @@
* business work (design ledger). No adjudication hooks: subagent
* references never enter command adjudication.
*/
import type { ClientContext, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ClientContext, SessionId, SubagentAddress,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ClientSessionContext, SlashServiceContract, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
import { SubagentCatalogAction, type SubagentCatalogInjected } from './SubagentCatalogAction.tsx'
import {
SubagentReadOnlyComposer, type SubagentReadOnlyMatch,
} from './SubagentReadOnlyComposer.tsx'
/** Required services: the slash registry + the session list face the source closes over. */
export const inject = ['slash', 'sessions']
export type {
SubagentCatalogActionProps, SubagentCatalogInjected,
} from './SubagentCatalogAction.tsx'
export type {
SubagentReadOnlyComposerProps, SubagentReadOnlyMatch,
} from './SubagentReadOnlyComposer.tsx'
/** Required services for references, conversation slots, and session navigation. */
export const inject = ['slash', 'sessions', 'conversation', 'slots']
/** Claim the composer for one-shot history or an unavailable continuation owner. */
function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatch | null {
const subagent = owner.session?.subagent
if (subagent === undefined || subagent === null) return null
if (subagent.address.mode === 'one-shot') return { reason: 'one-shot' }
return subagent.parentAvailable ? null : { reason: 'parent-unavailable' }
}
/**
* Client plugin body: register the '@' subagent source over the root session list.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
const sessions = ctx.get('sessions') as SessionsService
const sessions = ctx.sessions
// Child labels live on the session list (parentId lineage + displayTitle),
// not the conversation snapshot — the list store is the zero-RPC candidate feed.
const childLabels = (session: ClientSessionContext, query: string): string[] => {
@@ -59,4 +81,33 @@ export function apply(ctx: ClientContext): void {
}
const slash = ctx.get('slash') as SlashServiceContract
ctx.effect(() => slash.registerSource(source), 'ui-subagent: @ source')
const catalogActions = (_parentSessionId: SessionId): SubagentCatalogInjected => ({
openChild(address: SubagentAddress) {
sessions.openSubagent(address)
},
refresh(parentSessionId: SessionId) {
void sessions.refreshSubagents(parentSessionId)
},
setCatalogOpen(parentSessionId: SessionId, open: boolean) {
sessions.setSubagentCatalogOpen(parentSessionId, open)
},
})
ctx.effect(
() => ctx.slots.register({
name: 'conversation.session.header.actions',
id: 'subagent-catalog',
order: 10,
inject: catalogActions,
}, SubagentCatalogAction),
'ui-subagent: lazy descendant catalog action',
)
ctx.effect(
() => ctx.slots.register({
name: 'conversation.composer',
priority: -10,
select: selectReadOnlySubagent,
}, SubagentReadOnlyComposer),
'ui-subagent: read-only addressed composer',
)
}

View File

@@ -11,9 +11,19 @@
*/
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import {
SlotsService, type ConversationSnapshot, type SessionId, type SessionListState,
type SessionSummary, type SubagentAddress,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
import type { ClientSessionContext, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
import {
SubagentCatalogAction, type SubagentCatalogInjected,
} from '../src/client/SubagentCatalogAction.tsx'
import {
SubagentReadOnlyComposer, type SubagentReadOnlyMatch,
} from '../src/client/SubagentReadOnlyComposer.tsx'
import { apply, inject } from '../src/client/index.ts'
function summary(partial: Partial<SessionSummary> & { id: SessionId }): SessionSummary {
@@ -33,6 +43,7 @@ function sessionsWith(sessions: SessionSummary[]) {
for (const s of sessions) byId[s.id] = s
const snapshot = { ids: sessions.map(s => s.id), byId, current: undefined } as unknown as SessionListState
const subs = new Set<() => void>()
const actionCalls: { method: string; args: unknown[] }[] = []
return {
list: {
getSnapshot: () => snapshot,
@@ -40,9 +51,32 @@ function sessionsWith(sessions: SessionSummary[]) {
},
notify: () => { for (const fn of [...subs]) fn() },
listenerCount: () => subs.size,
actionCalls,
openSubagent: (address: SubagentAddress) => {
actionCalls.push({ method: 'openSubagent', args: [address] })
},
refreshSubagents: (parentSessionId: SessionId) => {
actionCalls.push({ method: 'refreshSubagents', args: [parentSessionId] })
return Promise.resolve()
},
setSubagentCatalogOpen: (parentSessionId: SessionId, open: boolean) => {
actionCalls.push({ method: 'setSubagentCatalogOpen', args: [parentSessionId, open] })
},
}
}
async function provideSlotFaces(ctx: Context): Promise<void> {
await ctx.plugin(SlotsService).await()
ctx.slots.register({
name: 'root',
children: {
'conversation.session.header.actions': { kind: 'list', scope: 'session' },
'conversation.composer': { kind: 'chain', scope: 'session' },
},
} as never, () => null)
ctx.provide('conversation', {})
}
/** Boot the plugin over fake slash/sessions faces; returns the captured source and the list face. */
async function fullBench(sessions: SessionSummary[]) {
const ctx = new Context()
@@ -50,8 +84,9 @@ async function fullBench(sessions: SessionSummary[]) {
const face = sessionsWith(sessions)
ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } })
ctx.provide('sessions', face)
await provideSlotFaces(ctx)
await ctx.plugin({ inject: [...inject], apply }).await()
return { source: captured!, face }
return { source: captured!, face, ctx }
}
/** Source-only bench for the behavior-contract suites. */
@@ -76,13 +111,14 @@ const req = (query: string) =>
describe('apply', () => {
it('declares the services it binds', () => {
expect(inject).toEqual(['slash', 'sessions'])
expect(inject).toEqual(['slash', 'sessions', 'conversation', 'slots'])
})
it('registers the "@" subagent source; disposal frees the name (HMR safety)', async () => {
const ctx = new Context()
await ctx.plugin(SlashService).await()
ctx.provide('sessions', sessionsWith(FAMILY))
await provideSlotFaces(ctx)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
const slash = ctx.get('slash') as SlashService
@@ -98,6 +134,45 @@ describe('apply', () => {
await fiber.dispose()
expect(() => slash.registerSource(rival)).not.toThrow()
})
it('registers catalog actions and selects read-only subagent composers from session facts', async () => {
const { ctx, face } = await fullBench(FAMILY)
const catalogEntry = ctx.slots.entries('conversation.session.header.actions')
.find(entry => entry.component === SubagentCatalogAction)!
const actions = (catalogEntry.inject as unknown as (id: SessionId) => SubagentCatalogInjected)(sid('parent'))
const address: SubagentAddress = {
parentSessionId: sid('parent'),
childSessionId: sid('c1'),
mode: 'continuable',
}
actions.openChild(address)
actions.refresh(sid('parent'))
actions.setCatalogOpen(sid('parent'), true)
expect(face.actionCalls).toEqual([
{ method: 'openSubagent', args: [address] },
{ method: 'refreshSubagents', args: [sid('parent')] },
{ method: 'setSubagentCatalogOpen', args: [sid('parent'), true] },
])
const composerEntry = ctx.slots.entries('conversation.composer')
.find(entry => entry.component === SubagentReadOnlyComposer)!
const select = composerEntry.select as (owner: ComposerChainProps) => SubagentReadOnlyMatch | null
const owner = (
subagent: ConversationSnapshot['subagent'] | undefined,
): ComposerChainProps => ({
interactions: [],
session: subagent === undefined
? undefined
: ({ subagent } as unknown as ConversationSnapshot),
})
expect(select(owner(undefined))).toBeNull()
expect(select(owner(null))).toBeNull()
expect(select(owner({ address: { ...address, mode: 'one-shot' }, parentAvailable: true })))
.toEqual({ reason: 'one-shot' })
expect(select(owner({ address, parentAvailable: true }))).toBeNull()
expect(select(owner({ address, parentAvailable: false })))
.toEqual({ reason: 'parent-unavailable' })
})
})
describe('candidates', () => {

View File

@@ -0,0 +1,465 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import type {
SessionId, SessionListState, SessionSummary, SubagentCatalogSnapshot,
} from '@deepseek-ai/dsh-client-runtime/client'
import {
SubagentCatalogAction, type SubagentCatalogActionProps,
} from '../src/client/SubagentCatalogAction.tsx'
import { SubagentReadOnlyComposer } from '../src/client/SubagentReadOnlyComposer.tsx'
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
const PARENT = 'parent' as SessionId
const CHILD = 'child' as SessionId
const GRANDCHILD = 'grandchild' as SessionId
function catalog(over: Partial<SubagentCatalogSnapshot> = {}): SubagentCatalogSnapshot {
return {
entries: [
{
kind: 'child', id: CHILD, mode: 'continuable', label: 'worker',
activity: 'running', hasChildren: true,
},
{
kind: 'child', id: 'child-2' as SessionId, mode: 'one-shot',
label: 'reviewer', activity: 'inactive', hasChildren: false,
},
{ kind: 'diagnostic', id: 'bad' as SessionId, reason: 'corrupt' },
],
parentAvailable: true,
state: 'ready',
error: null,
...over,
}
}
function props(
value: SubagentCatalogSnapshot | undefined,
nested: Readonly<Record<SessionId, SubagentCatalogSnapshot>> = {},
summaries?: Readonly<Record<SessionId, SessionSummary>>,
) {
const state = {
ids: [CHILD],
byId: summaries ?? {
[CHILD]: {
id: CHILD,
title: '正在扫描项目文件',
displayTitle: 'worker',
running: true,
blank: false,
waitingApproval: false,
updatedAt: Date.now(),
},
},
current: PARENT, phase: 'ready',
subagentsByParent: value === undefined ? nested : { [PARENT]: value, ...nested },
currentAddress: undefined,
} satisfies SessionListState
function useSessions<T>(select: (snapshot: SessionListState) => T): T {
return select(state)
}
return {
sessionId: PARENT,
useSessions,
openChild: vi.fn(),
refresh: vi.fn(),
setCatalogOpen: vi.fn(),
} as unknown as SubagentCatalogActionProps
}
function summary(id: SessionId, updatedAt: number): SessionSummary {
return {
id,
displayTitle: id,
running: false,
blank: false,
waitingApproval: false,
updatedAt,
}
}
describe('SubagentCatalogAction', () => {
it('aggregates live descendant activity onto the closed trigger', () => {
const summaries: Record<SessionId, SessionSummary> = {
[CHILD]: {
...summary(CHILD, Date.now()),
parentId: PARENT,
origin: 'subagent',
},
[GRANDCHILD]: {
...summary(GRANDCHILD, Date.now()),
parentId: CHILD,
origin: 'subagent',
running: true,
},
['child-2' as SessionId]: {
...summary('child-2' as SessionId, Date.now()),
parentId: PARENT,
origin: 'subagent',
},
}
const view = render(<SubagentCatalogAction {...props(catalog(), {}, summaries)} />)
const trigger = screen.getByRole('button', { name: '3 个子代理,正在运行' })
expect(trigger.querySelector('[data-state="ongoing"]')).not.toBeNull()
view.rerender(<SubagentCatalogAction {...props(catalog(), {}, {
...summaries,
[GRANDCHILD]: { ...summaries[GRANDCHILD]!, running: false },
})} />)
expect(screen.getByRole('button', { name: '3 个子代理' })
.querySelector('[data-state="ongoing"]')).toBeNull()
})
it('does not aggregate subagents reached through an ordinary fork', () => {
const fork = 'fork' as SessionId
const forkChild = 'fork-child' as SessionId
render(<SubagentCatalogAction {...props(catalog(), {}, {
[CHILD]: { ...summary(CHILD, 1), parentId: PARENT, origin: 'subagent' },
['child-2' as SessionId]: {
...summary('child-2' as SessionId, 1), parentId: PARENT, origin: 'subagent',
},
[fork]: { ...summary(fork, 1), parentId: PARENT },
[forkChild]: { ...summary(forkChild, 1), parentId: fork, origin: 'subagent', running: true },
})} />)
const trigger = screen.getByRole('button', { name: '2 个子代理' })
expect(trigger.querySelector('[data-state="ongoing"]')).toBeNull()
})
it('renders healthy counts, stable rows, diagnostics, and catalog-addressed navigation', () => {
const input = props(catalog())
render(<SubagentCatalogAction {...input} />)
const trigger = screen.getByRole('button', { name: /2 个子代理/ })
fireEvent.click(trigger)
expect(input.setCatalogOpen).toHaveBeenCalledWith(PARENT, true)
expect(screen.getAllByRole('treeitem')).toHaveLength(3)
expect(screen.getByText('正在扫描项目文件 · 可继续 · 正在运行')).toBeTruthy()
expect(screen.getByText('一次性 · 当前未运行')).toBeTruthy()
const diagnostic = screen.getByRole('treeitem', { name: /会话记录损坏/ })
expect(diagnostic.getAttribute('aria-disabled')).toBe('true')
expect(screen.getByRole('button', { name: '展开 worker 的下级子代理' })).toBeTruthy()
expect(screen.queryByRole('button', { name: '展开 reviewer 的下级子代理' })).toBeNull()
fireEvent.click(screen.getByRole('treeitem', { name: /worker/ }))
expect(input.openChild).toHaveBeenCalledWith({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
})
expect(input.setCatalogOpen).toHaveBeenLastCalledWith(PARENT, false)
})
it('supports trigger/menu keyboard traversal, Escape focus restore, and outside close', async () => {
const input = props(catalog())
render(<SubagentCatalogAction {...input} />)
const trigger = screen.getByRole('button', { name: /2 个子代理/ })
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
await Promise.resolve()
expect(document.activeElement).toBe(screen.getByRole('treeitem', { name: /worker/ }))
fireEvent.keyDown(document.activeElement as Element, { key: 'End' })
expect(document.activeElement).toBe(screen.getByRole('treeitem', { name: /reviewer/ }))
fireEvent.keyDown(document.activeElement as Element, { key: 'Home' })
expect(document.activeElement).toBe(screen.getByRole('treeitem', { name: /worker/ }))
fireEvent.keyDown(document.activeElement as Element, { key: 'ArrowUp' })
expect(document.activeElement).toBe(screen.getByRole('treeitem', { name: /reviewer/ }))
fireEvent.keyDown(document.activeElement as Element, { key: 'Escape' })
await Promise.resolve()
expect(screen.queryByRole('tree')).toBeNull()
expect(document.activeElement).toBe(trigger)
fireEvent.click(trigger)
fireEvent.pointerDown(screen.getByRole('tree'))
expect(screen.getByRole('tree')).toBeTruthy()
fireEvent.pointerDown(document.body)
expect(screen.queryByRole('tree')).toBeNull()
})
it('covers diagnostic variants, fallback labels, and keyboard row activation', () => {
const unsupported = 'unsupported' as SessionId
const unavailable = 'unavailable' as SessionId
const unlabeled = 'unlabeled' as SessionId
const input = props(catalog({
entries: [
{ kind: 'diagnostic', id: unsupported, reason: 'unsupported' },
{ kind: 'diagnostic', id: unavailable, reason: 'unavailable' },
{
kind: 'child', id: CHILD, mode: 'continuable', label: 'worker',
activity: 'running', hasChildren: false,
},
{
kind: 'child', id: unlabeled, mode: 'one-shot',
activity: 'inactive', hasChildren: false,
},
],
}))
render(<SubagentCatalogAction {...input} />)
const trigger = screen.getByRole('button', { name: /2 个子代理/ })
fireEvent.keyDown(trigger, { key: 'Tab' })
expect(screen.queryByRole('tree')).toBeNull()
fireEvent.click(trigger)
expect(screen.getByRole('treeitem', { name: /子代理记录版本不受支持/ })).toBeTruthy()
expect(screen.getByRole('treeitem', { name: /会话记录暂不可用/ })).toBeTruthy()
fireEvent.keyDown(screen.getByRole('treeitem', { name: /worker/ }), { key: 'Enter' })
expect(input.openChild).toHaveBeenLastCalledWith({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
})
fireEvent.click(trigger)
fireEvent.keyDown(screen.getByRole('treeitem', { name: /unlabeled/ }), { key: ' ' })
expect(input.openChild).toHaveBeenLastCalledWith({
parentSessionId: PARENT, childSessionId: unlabeled, mode: 'one-shot',
})
})
it('renders compact activity times across every unit and clamps future timestamps', () => {
const now = 2_000_000_000_000
vi.spyOn(Date, 'now').mockReturnValue(now)
const minute = 60_000
const hour = 60 * minute
const day = 24 * hour
const rows = [
['future', now + minute],
['minutes', now - 2 * minute],
['hours', now - 2 * hour],
['days', now - 2 * day],
['months', now - 60 * day],
['years', now - 2 * 365 * day],
] as const
const entries = rows.map(([id]) => ({
kind: 'child' as const,
id: id as SessionId,
mode: 'continuable' as const,
label: id,
activity: 'inactive' as const,
hasChildren: false,
}))
const summaries = Object.fromEntries(rows.map(([id, updatedAt]) => [
id,
summary(id as SessionId, updatedAt),
])) as Record<SessionId, SessionSummary>
const input = props(catalog({ entries }), {}, summaries)
render(<SubagentCatalogAction {...input} />)
fireEvent.click(screen.getByRole('button', { name: /6 个子代理/ }))
expect(screen.getByRole('treeitem', { name: /future.*刚刚/ })).toBeTruthy()
expect(screen.getByRole('treeitem', { name: /minutes.*2分钟/ })).toBeTruthy()
expect(screen.getByRole('treeitem', { name: /hours.*2小时/ })).toBeTruthy()
expect(screen.getByRole('treeitem', { name: /days.*2天/ })).toBeTruthy()
expect(screen.getByRole('treeitem', { name: /months.*2个月/ })).toBeTruthy()
expect(screen.getByRole('treeitem', { name: /years.*2年/ })).toBeTruthy()
})
it('lazily expands and collapses descendant catalogs with direct-parent navigation', () => {
const childCatalog = catalog({
entries: [
{
kind: 'child', id: GRANDCHILD, mode: 'continuable',
label: 'indexer', activity: 'inactive', hasChildren: false,
},
],
})
const grandchildCatalog = catalog({ entries: [] })
const input = props(catalog(), {
[CHILD]: childCatalog,
[GRANDCHILD]: grandchildCatalog,
})
render(<SubagentCatalogAction {...input} />)
fireEvent.click(screen.getByRole('button', { name: /2 个子代理/ }))
fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子代理' }))
expect(input.setCatalogOpen).toHaveBeenCalledWith(CHILD, true)
const nested = screen.getByRole('treeitem', { name: /indexer/ })
expect(nested.getAttribute('aria-level')).toBe('2')
fireEvent.click(nested)
expect(input.openChild).toHaveBeenCalledWith({
parentSessionId: CHILD, childSessionId: GRANDCHILD, mode: 'continuable',
})
expect(input.setCatalogOpen).toHaveBeenCalledWith(PARENT, false)
expect(input.setCatalogOpen).toHaveBeenCalledWith(CHILD, false)
})
it('shows known descendant rows while their catalog loads', () => {
const secondGrandchild = 'grandchild-2' as SessionId
const summaries = {
[GRANDCHILD]: {
...summary(GRANDCHILD, 1), parentId: CHILD, origin: 'subagent' as const,
},
[secondGrandchild]: {
...summary(secondGrandchild, 1), parentId: CHILD, origin: 'subagent' as const,
running: true,
},
}
const deferred = props(catalog(), {}, summaries)
const view = render(<SubagentCatalogAction {...deferred} />)
fireEvent.click(screen.getByRole('button', { name: /2 个子代理/ }))
fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子代理' }))
expect(deferred.setCatalogOpen).toHaveBeenCalledWith(CHILD, true)
expect(screen.getByRole('group').getAttribute('aria-busy')).toBe('true')
const loadingRows = screen.getAllByRole('treeitem', { name: '正在加载子代理' })
expect(loadingRows).toHaveLength(2)
expect(loadingRows.every(row => row.getAttribute('aria-level') === '2')).toBe(true)
expect(loadingRows[1]?.querySelector('[data-state="ongoing"]')).not.toBeNull()
const loading = props(catalog(), {
[CHILD]: catalog({ entries: [], state: 'loading' }),
}, summaries)
view.rerender(<SubagentCatalogAction {...loading} />)
expect(screen.getAllByRole('treeitem', { name: '正在加载子代理' })).toHaveLength(2)
const ready = props(catalog(), {
[CHILD]: catalog({
entries: [
{
kind: 'child', id: GRANDCHILD, mode: 'continuable',
label: 'indexer', activity: 'inactive', hasChildren: false,
},
{
kind: 'child', id: secondGrandchild, mode: 'one-shot',
label: 'critic', activity: 'running', hasChildren: false,
},
],
}),
}, summaries)
view.rerender(<SubagentCatalogAction {...ready} />)
expect(screen.getByRole('group').getAttribute('aria-busy')).toBeNull()
expect(screen.getByRole('treeitem', { name: /indexer/ })).toBeTruthy()
expect(screen.getByRole('treeitem', { name: /critic/ })).toBeTruthy()
expect(screen.queryByRole('treeitem', { name: '正在加载子代理' })).toBeNull()
})
it('uses ArrowRight and ArrowLeft for branch disclosure', async () => {
const input = props(catalog(), {
[CHILD]: catalog({
entries: [{
kind: 'child', id: GRANDCHILD, mode: 'continuable',
label: 'indexer', activity: 'running', hasChildren: false,
}],
}),
})
render(<SubagentCatalogAction {...input} />)
const trigger = screen.getByRole('button', { name: /2 个子代理/ })
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
await Promise.resolve()
const worker = screen.getByRole('treeitem', { name: /worker/ })
fireEvent.keyDown(worker, { key: 'ArrowRight' })
expect(screen.getByRole('treeitem', { name: /indexer/ })).toBeTruthy()
fireEvent.keyDown(worker, { key: 'ArrowLeft' })
expect(screen.queryByRole('treeitem', { name: /indexer/ })).toBeNull()
expect(input.setCatalogOpen).toHaveBeenCalledWith(CHILD, false)
})
it('closes expanded descendants even when their own catalogs have not arrived', () => {
const input = props(catalog(), {
[CHILD]: catalog({
entries: [
{
kind: 'child', id: GRANDCHILD, mode: 'continuable',
label: 'indexer', activity: 'running', hasChildren: true,
},
{ kind: 'diagnostic', id: 'nested-bad' as SessionId, reason: 'corrupt' },
],
}),
})
render(<SubagentCatalogAction {...input} />)
fireEvent.click(screen.getByRole('button', { name: /2 个子代理/ }))
fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子代理' }))
fireEvent.click(screen.getByRole('button', { name: '展开 indexer 的下级子代理' }))
fireEvent.click(screen.getByRole('button', { name: '收起 worker 的下级子代理' }))
expect(input.setCatalogOpen).toHaveBeenCalledWith(GRANDCHILD, false)
expect(input.setCatalogOpen).toHaveBeenCalledWith(CHILD, false)
expect(screen.queryByRole('treeitem', { name: /indexer/ })).toBeNull()
})
it('hides an arrived empty catalog and exposes retry for a failed one', () => {
const absent = render(<SubagentCatalogAction {...props(undefined)} />)
expect(screen.queryByRole('button')).toBeNull()
absent.unmount()
const empty = props(catalog({ entries: [] }))
const view = render(<SubagentCatalogAction {...empty} />)
expect(screen.queryByRole('button')).toBeNull()
view.unmount()
const failed = props(catalog({
entries: [],
state: 'error',
error: { code: 'internal', message: 'index down', details: {} },
}))
render(<SubagentCatalogAction {...failed} />)
fireEvent.click(screen.getByRole('button', { name: /0 个子代理/ }))
expect(screen.getByText('index down')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: /重试/ }))
expect(failed.refresh).toHaveBeenCalledWith(PARENT)
})
it('renders empty loading and fallback error states without focusable rows', async () => {
const loading = props(catalog({ entries: [], state: 'loading' }))
const view = render(<SubagentCatalogAction {...loading} />)
const trigger = screen.getByRole('button', { name: /0 个子代理/ })
fireEvent.click(trigger)
expect(screen.getByText('正在加载子代理…')).toBeTruthy()
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
await Promise.resolve()
expect(screen.getByRole('tree')).toBeTruthy()
fireEvent.keyDown(screen.getByRole('tree'), { key: 'ArrowUp' })
view.unmount()
const failed = props(catalog({ entries: [], state: 'error', error: null }))
render(<SubagentCatalogAction {...failed} />)
fireEvent.click(screen.getByRole('button', { name: /0 个子代理/ }))
expect(screen.getByText('无法加载子代理')).toBeTruthy()
})
it('navigates from outside the tree and tolerates a deferred focus after unmount', async () => {
const input = props(catalog())
const view = render(<SubagentCatalogAction {...input} />)
const trigger = screen.getByRole('button', { name: /2 个子代理/ })
fireEvent.click(trigger)
fireEvent.keyDown(screen.getByRole('tree'), { key: 'ArrowUp' })
expect(document.activeElement).toBe(screen.getByRole('treeitem', { name: /reviewer/ }))
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
view.unmount()
await Promise.resolve()
})
it('closes every observed catalog when the root becomes empty', () => {
const populated = props(catalog(), {
[CHILD]: catalog({
entries: [{
kind: 'child', id: GRANDCHILD, mode: 'continuable',
label: 'indexer', activity: 'inactive', hasChildren: false,
}],
}),
})
const view = render(<SubagentCatalogAction {...populated} />)
fireEvent.click(screen.getByRole('button', { name: /2 个子代理/ }))
fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子代理' }))
const empty = props(catalog({ entries: [] }))
view.rerender(<SubagentCatalogAction {...empty} />)
expect(screen.queryByRole('button')).toBeNull()
expect(empty.setCatalogOpen).toHaveBeenCalledWith(PARENT, false)
expect(empty.setCatalogOpen).toHaveBeenCalledWith(CHILD, false)
})
})
describe('SubagentReadOnlyComposer', () => {
it('explains the exact missing-parent recovery path', () => {
render(<SubagentReadOnlyComposer matched={{ reason: 'parent-unavailable' }} />)
expect(screen.getByRole('status').textContent).toContain('父会话当前不在线')
})
it('explains that one-shot histories never accept follow-ups', () => {
render(<SubagentReadOnlyComposer matched={{ reason: 'one-shot' }} />)
expect(screen.getByRole('status').textContent).toContain('一次性任务不支持后续消息')
})
})

View File

@@ -14,6 +14,12 @@
{
"path": "../runtime"
},
{
"path": "../ui-conversation"
},
{
"path": "../ui-primitives"
},
{
"path": "../ui-slash"
},

View File

@@ -22,7 +22,7 @@ const COPY: Record<string, string> = {
/** Empty global standard-kit hooks (the row reads neither). */
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready' })
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
return bindSnapshotSelector(store)
}
function emptyWorkspaces() {

View File

@@ -23,6 +23,7 @@ import type {
import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { ConversationSession, type ConversationSessionProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx'
import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts'
import { zh as conversationZh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory'
import type { TrajectoryTurnModel } from '../src/client/layout.ts'
@@ -35,6 +36,11 @@ import { deriveTrajectoryTimeline } from '../src/client/timeline.ts'
const SID = 's1' as SessionId
// Stub of the conversation package's standard locale seat (this spec mounts
// its ConversationSession chrome); answers from the zh dictionary and falls
// back to the key like the real chain.
const tConversation: ConversationSessionProps['t'] =
key => (conversationZh as Record<string, string>)[key] ?? key
afterEach(cleanup)
// The chat store persists under its declared key; clear so one case's active
// view cannot rehydrate into the next.
@@ -112,7 +118,7 @@ function fakeSession(nodes: ConversationSnapshot['nodes']) {
/** Empty sessions-list hook; breadcrumbs therefore fall back to the raw id. */
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready' })
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
return bindSnapshotSelector(store)
}
@@ -213,6 +219,7 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
return render(
<ConversationSession
sessionId={SID}
t={tConversation}
SessionProvider={({ children }) => children(SID)}
useSession={useSession}
useSessions={emptySessions()}
@@ -229,6 +236,7 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
useInput={bindSnapshotSelector(createSnapshotStore({ draft: '', draftRev: 0, phase: 'plain', queue: [] })) as never}
inputActions={{ setDraft: vi.fn(), submit: vi.fn() }}
bindDraftMirror={() => () => {}}
open={vi.fn()}
/>,
)
}

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md
README.md: 2854c678fd93d56267d0aec8515455b77d4bcbf1
README.zh.md: 51db6cacfe83d176a6cc68b01fe3dd91acf49e25
README.md: 2670bdfa2fb1a223bf0c0ea65fbacc1cfb30c607
README.zh.md: 1e68cfb1a94c240c32949059ca6a3c6adc25208b

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