Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml
#	docs/architecture.i18n.yaml
#	docs/architecture.md
#	docs/architecture.zh.md
#	docs/config-catalog.md
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.i18n.yaml
#	docs/core-data-structures/core.md
#	docs/core-data-structures/core.zh.md
#	docs/event-producer-consumer.md
#	docs/module-graph.md
#	docs/persistence-catalog.md
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl
#	examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	examples/acp-agent/tests/snapshots/skill-load/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl
#	examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl
#	examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl
#	examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl
#	examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl
#	examples/acp-agent/tests/snapshots/workspace-context/session.jsonl
#	examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl
#	examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl
#	examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl
#	examples/headless-agent/tests/snapshots/pty-tools/session.jsonl
#	examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl
#	examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl
#	examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl
#	packages/acp/acp/README.i18n.yaml
#	packages/acp/acp/src/index.ts
#	packages/client/connection/src/client/index.ts
#	packages/client/runtime/README.i18n.yaml
#	packages/client/runtime/README.md
#	packages/client/runtime/README.zh.md
#	packages/client/runtime/src/client/sessions/conversation.ts
#	packages/client/runtime/src/client/sessions/session.ts
#	packages/client/runtime/tests/queue-store.spec.ts
#	packages/client/ui-conversation/src/client/input/contract.ts
#	packages/client/ui-goal/README.i18n.yaml
#	packages/compact/compact-basic/src/index.ts
#	packages/context/time-context/tests/time-context.spec.ts
#	packages/context/tmux-context/tests/tmux-context.spec.ts
#	packages/context/workspace-context/tests/workspace-context.spec.ts
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/core/agent-loop/README.i18n.yaml
#	packages/core/agent-loop/README.md
#	packages/core/agent-loop/README.zh.md
#	packages/core/agent-loop/src/agent.ts
#	packages/core/agent-loop/tests/agent.spec.ts
#	packages/core/agent-loop/tests/loop.spec.ts
#	packages/core/agent/README.i18n.yaml
#	packages/core/agent/README.md
#	packages/core/agent/README.zh.md
#	packages/core/agent/src/types.ts
#	packages/core/session/README.i18n.yaml
#	packages/core/session/README.md
#	packages/core/session/README.zh.md
#	packages/goal/command-goal/tests/command-goal.spec.ts
#	packages/goal/goal/tests/goal.spec.ts
#	packages/goal/tool-goal/README.i18n.yaml
#	packages/goal/tool-goal/README.md
#	packages/goal/tool-goal/README.zh.md
#	packages/host/apiproxy/README.i18n.yaml
#	packages/host/apiproxy/README.md
#	packages/host/apiproxy/README.zh.md
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/src/api/events.schema.ts
#	packages/host/apiproxy/src/api/events.ts
#	packages/host/apiproxy/src/api/rpc.ts
#	packages/host/apiproxy/tests/api-proxy-commands.spec.ts
#	packages/host/apiproxy/tests/api-proxy-projections.spec.ts
#	packages/host/apiproxy/tests/rpc-schemas.spec.ts
#	packages/pty/pty-local/tests/index.spec.ts
#	packages/pty/pty-local/tests/local.spec.ts
#	packages/pty/tool-pty/tests/loader-composition.spec.ts
#	packages/pty/tool-pty/tests/tools.spec.ts
#	packages/session-persistence/session-checkpoint-policy/src/index.ts
#	packages/session-persistence/session-persistence/tests/coordinator-contract.ts
#	packages/skill/tool-skill/tests/tool-skill.spec.ts
#	packages/subagent/subagent-inprocess/README.i18n.yaml
#	packages/subagent/subagent-inprocess/README.md
#	packages/subagent/subagent-inprocess/README.zh.md
#	packages/subagent/subagent-inprocess/src/index.ts
#	packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts
#	packages/support/acp-snapshot/src/harness.ts
#	packages/ui/tui/tests/tui.spec.ts
This commit is contained in:
_Kerman
2026-08-03 10:56:09 +08:00
605 changed files with 25740 additions and 2265 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/README.md
README.md: 0c729f781151fcc0bda81899e51227e71c7b8d2b
README.zh.md: 660a24eeea5f1a36841654626d94412371a2f462
README.md: c8984bfa652a0ad7e12bc1f2001618df452bc863
README.zh.md: 2a59a63d22cdb2e5c0de53cd1dcfce1296882c01

View File

@@ -33,7 +33,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
| [`plan/`](plan/README.md) | Plan collaboration state with a direct entry command and reviewed exit | Product — stable surface |
| [`timeout/`](timeout/README.md) | Tool-call timeout policy: the `tools/execute` deadline enforcer | Product — stable surface |
| [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders | Product — stable surface |
| [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface |
| [`cordis/`](cordis/README.md) | Cordis runtime integration: self-inspection/model-written temporary Plugins and restricted repository Plugin loading | Product — stable surface |
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
| [`session-persistence/`](session-persistence/README.md) | Persistence seam + JSONL/SQLite backends | Product — stable surface |
| [`session-projection/`](session-projection/README.md) | Projection seam: domain fold units serve whole values | Product — stable surface |

View File

@@ -33,7 +33,7 @@
| [`plan/`](plan/README.md) | Plan 协作状态,提供直接进入命令与经评审的退出 | 产品:稳定表面 |
| [`timeout/`](timeout/README.md) | 工具调用超时策略:`tools/execute` 截止时间强制执行器 | 产品:稳定表面 |
| [`guard/`](guard/README.md) | 循环卫生守卫:建议性重复调用提醒 | 产品:稳定表面 |
| [`cordis/`](cordis/README.md) | 自指运行时工具集:检查实时运行时的插件与服务,挂载/卸载模型所写插件([设计](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md) | 产品:稳定表面 |
| [`cordis/`](cordis/README.md) | Cordis 运行时集成:自检/模型编写的临时 Plugin以及受限 repository Plugin 加载 | 产品:稳定表面 |
| [`hooks/`](hooks/README.md) | 钩子桥接 + 共享 Claude CodeCodex 协议格式库 | 产品:稳定表面 |
| [`session-persistence/`](session-persistence/README.md) | 持久化 seam + JSONL/SQLite 后端 | 产品:稳定表面 |
| [`session-projection/`](session-projection/README.md) | 投影 seam领域折叠单元供给全量值 | 产品:稳定表面 |

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.
ACP requires each prompt response to carry a `stopReason`, but the bridge does not claim a prompt-specific turn outcome. Committed assistant messages stream across the owned activity, and steering or injected work may contribute before idle. Token-limit and model-error turn endings therefore do not become prompt-level ACP stop reasons.

View File

@@ -35,7 +35,7 @@
## 生命周期
客户端断开连接与 Cordis 的 dispose资源释放共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,结算待处理提示词,然后并行对其拥有的全部 agent 句柄执行 dispose并等待它们的循环会话清理完成。因此单独重载 ACP 插件不会遗留孤儿 agent。
客户端断开与 Cordis 释放共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,结算待处理提示词,然后只 drain 此连接确切拥有的 Agent 之下的可继续后代,再并行释放这些 handle并等待全部结果结算后才报告失败。其他共享该上下文的前端会保留其可继续森林和准入。因此 ACP 插件重载不会遗留 agent。
ACP 要求每个提示词响应都携带 `stopReason`,但桥接层不声称它表示提示词专属的轮次结果。已提交的 assistant 消息会在整个自有活动期间流式输出agent 进入 idle 前发生的 steering中途引导或注入工作也可能参与其中。因此因 token 上限或模型错误而结束的轮次不会成为提示词级 ACP 停止原因。

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,129 @@ 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 and reports nested failure reasons', 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 AggregateError([
new Error('scope cleanup failed', { cause: new Error('sqlite busy') }),
new Error('hook cleanup failed'),
], '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): '
+ 'first session cleanup failed [scope cleanup failed: sqlite busy; hook cleanup failed]',
))).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

@@ -354,6 +354,9 @@ const BACKGROUND_OUTPUT_PROPERTIES = {
} as const
export function apply(ctx: Context, config: Config = {}): void {
// FIXME(bash-env-ownership): Move ctx.bashEnv to a tool-independent shell
// environment plugin; replacing this tool with persistent Bash must not
// remove the managed DSH_* contributor seam.
const bashEnv = new BashEnvRegistry(ctx, config)
bashEnv.register({
name: 'session-persistence',

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

@@ -1891,6 +1891,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
@@ -2401,6 +2414,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

@@ -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

@@ -39,9 +39,9 @@ export interface ISession {
*/
prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>>
/**
* Apply one mutation to a still-pending queue occurrence.
* Apply one edit, remove, or strict steer action to a still-pending queue occurrence.
* @param itemId - agent-owned inbox occurrence identity.
* @param action - edit or remove operation.
* @param action - requested queue operation.
* @returns acceptance, or a business/transport error.
*/
updateQueue(itemId: MessageId, action: QueueAction): Promise<RpcResult<{ accepted: true }>>

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

@@ -146,7 +146,8 @@ function materializeNode(
}
case 'steering/message':
return {
kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn,
kind: 'steering', messageId: event.data.message.id,
seq: event.seq, time: event.time, turn: event.data.turn,
content: event.data.message.content, source: event.data.message.source,
}
case 'tool/result': {

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,22 @@ 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'>
/** Removal-time invalidation replayed over the response this request predates. */
parentAvailableOverride: false | undefined
}
type SessionListMutation =
@@ -84,6 +100,13 @@ 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>()
/** Catalog owners whose membership changed while a pull was in flight: one trailing refresh after it settles. */
private readonly catalogStale = new Set<SessionId>()
private readonly openCatalogs = new Set<SessionId>()
private readonly catalogDebounce = new Map<SessionId, ReturnType<typeof setTimeout>>()
private selected: SessionId | undefined
@@ -104,22 +127,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 +180,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 +245,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 +284,99 @@ 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) {
const parentAvailable = this.catalogInflight.get(parentSessionId)?.parentAvailableOverride
?? result.value.parentAvailable
this.catalogs.set(parentSessionId, {
...result.value,
entries: this.withCatalogMutations(result.value.entries, expandableRows, activityRows),
parentAvailable,
state: 'ready',
error: null,
})
for (const [childId, address] of this.addresses) {
if (address.parentSessionId !== parentSessionId) continue
this.sessions.get(childId)?.handleSubagentParentAvailable(parentAvailable)
}
} else {
this.catalogs.set(parentSessionId, {
entries: this.withCatalogMutations(
previous?.entries ?? [], expandableRows, activityRows,
),
parentAvailable: this.catalogInflight.get(parentSessionId)?.parentAvailableOverride
?? 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: this.catalogInflight.get(parentSessionId)?.parentAvailableOverride
?? previous?.parentAvailable ?? false,
state: 'error',
error: folded.ok ? null : folded.error,
})
} finally {
this.catalogInflight.delete(parentSessionId)
// Re-arm the trailing pull before the dirty notify: the response the
// caller observed predates the stale-marking change, so the follow-up
// refresh is the only carrier of that change.
if (this.catalogStale.delete(parentSessionId)) void this.refreshSubagents(parentSessionId)
this.notifier.markDirty()
}
})()
this.catalogInflight.set(parentSessionId, {
promise: operation,
expandableRows,
activityRows,
parentAvailableOverride: undefined,
})
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 +661,65 @@ 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)
// A pull already in flight was requested before this removal and can
// carry the pre-removal parentAvailable:true, which would resurrect
// the writable editor this invalidation just closed. Replay false over
// that response and queue one trailing refresh so the post-removal
// host truth converges.
const inflightCatalog = this.catalogInflight.get(frame.sessionId)
if (inflightCatalog !== undefined) {
inflightCatalog.parentAvailableOverride = false
this.catalogStale.add(frame.sessionId)
}
// The removed session can no longer be the delivery owner of its
// catalog: invalidate availability immediately. Removal schedules no
// catalog refresh, and without this an addressed child keeps a
// writable editor against a dead continuation owner until an
// unrelated refresh (or forever, for a closed menu).
const ownedCatalog = this.catalogs.get(frame.sessionId)
if (ownedCatalog !== undefined && ownedCatalog.parentAvailable) {
this.catalogs.set(frame.sessionId, { ...ownedCatalog, parentAvailable: false })
}
for (const [childId, address] of this.addresses) {
if (address.parentSessionId !== frame.sessionId) continue
this.sessions.get(childId)?.handleSubagentParentAvailable(false)
}
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 +758,90 @@ 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 selected or open. */
private scheduleCatalogRefresh(parentSessionId: SessionId): void {
if (this.catalogDebounce.has(parentSessionId)) return
const timer = setTimeout(() => {
this.catalogDebounce.delete(parentSessionId)
// The in-flight response predates the membership frame that scheduled
// this callback. Queue one post-settlement pull instead of treating an
// ordinary overlapping read as evidence that catalog membership changed.
if (this.catalogInflight.has(parentSessionId)) {
this.catalogStale.add(parentSessionId)
return
}
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 +858,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 +870,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 +880,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 +900,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

@@ -74,7 +74,8 @@ function materializeNode(
}
case 'steering/message':
return {
kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn,
kind: 'steering', messageId: event.data.message.id,
seq: event.seq, time: event.time, turn: event.data.turn,
content: event.data.message.content, source: event.data.message.source,
}
case 'tool/result': {

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,402 @@ 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' },
])
})
it('coalesces overlapping catalog reads without scheduling a trailing pull', async () => {
const api = new FakeApiClient()
const root = 'fk-root' as SessionId
const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
api.onSubagentList = () => first.promise
const manager = new SessionManager(api)
const refresh = manager.refreshSubagents(root)
expect(manager.refreshSubagents(root)).toBe(refresh)
api.onSubagentList = () => Promise.resolve(ok({ entries: [], parentAvailable: true }))
first.resolve(ok({ entries: [], parentAvailable: true }))
await refresh
expect(api.callsOf('subagent.list')).toHaveLength(1)
})
it('runs one trailing catalog refresh for a membership change coalesced into an in-flight pull', async () => {
vi.useFakeTimers()
try {
const api = new FakeApiClient()
const root = 'fk-root' as SessionId
const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
const second = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
api.onSubagentList = () => first.promise
const manager = new SessionManager(api, root)
const refresh = manager.refreshSubagents(root)
// A membership frame arrives while the pull is in flight; the debounced
// refresh it schedules fires 50ms later and is coalesced into the pull —
// which was requested before the new child existed. The stale mark must
// queue one trailing pull carrying the change.
manager.handleHostEnvelope({
rpcId: 'child-added' as never,
payload: {
type: 'host/session-added', sessionId: S2, parentSessionId: root, blank: false,
},
})
await vi.advanceTimersByTimeAsync(50)
api.onSubagentList = () => second.promise
first.resolve(ok({
entries: [{
kind: 'child', id: S1, mode: 'continuable', label: 'older',
activity: 'inactive', hasChildren: false,
}] as never[],
parentAvailable: true,
}))
await refresh
// The trailing pull is already in flight (kicked synchronously in finally).
second.resolve(ok({
entries: [
{
kind: 'child', id: S1, mode: 'continuable', label: 'older',
activity: 'inactive', hasChildren: false,
},
{
kind: 'child', id: S2, mode: 'continuable', label: 'new child',
activity: 'inactive', hasChildren: false,
},
] as never[],
parentAvailable: true,
}))
await second.promise
expect(api.callsOf('subagent.list')).toHaveLength(2)
expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
{ kind: 'child', id: S1, label: 'older' },
{ kind: 'child', id: S2, label: 'new child' },
])
} finally {
vi.useRealTimers()
}
})
it('keeps removal invalidation across a stale success and failed trailing pull', async () => {
const api = new FakeApiClient()
const root = 'fk-root' as SessionId
const child = () => ({
kind: 'child' as const, id: S2, mode: 'continuable' as const, label: 'worker',
activity: 'inactive' as const, hasChildren: false,
})
const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
api.onSubagentList = () => first.promise
const manager = new SessionManager(api)
const refresh = manager.refreshSubagents(root)
first.resolve(ok({ entries: [child()] as never[], parentAvailable: true }))
await refresh
manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' })
// The removal lands while a second pull is in flight: the invalidation
// must survive the pre-removal ok response, so one trailing pull runs.
const mid = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
api.onSubagentList = () => mid.promise
const midRefresh = manager.refreshSubagents(root)
manager.handleHostEnvelope({
rpcId: 'parent-removed-mid-pull' as never,
payload: { type: 'host/session-removed', sessionId: root },
})
const trailing = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
api.onSubagentList = () => trailing.promise
mid.resolve(ok({ entries: [child()] as never[], parentAvailable: true }))
await midRefresh
expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false)
expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false })
trailing.resolve(err({ code: 'internal', message: 'trailing pull failed', details: {} }))
await vi.waitFor(() => {
expect(manager.getListSnapshot().subagentsByParent[root]).toMatchObject({
state: 'error',
parentAvailable: false,
})
})
const rootCalls = api.callsOf('subagent.list')
.filter(call => (call as { parentSessionId: SessionId }).parentSessionId === root)
expect(rootCalls).toHaveLength(3)
expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false)
expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false })
})
it('invalidates catalog availability when the owning parent is removed', async () => {
const api = new FakeApiClient()
const root = 'fk-root' as SessionId
api.onSubagentList = () => Promise.resolve(ok({
entries: [{
kind: 'child', id: S2, mode: 'continuable', label: 'worker',
activity: 'inactive', hasChildren: false,
}] as never[],
parentAvailable: true,
}))
const manager = new SessionManager(api)
await manager.refreshSubagents(root)
manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' })
expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: true })
manager.handleHostEnvelope({
rpcId: 'parent-removed' as never,
payload: { type: 'host/session-removed', sessionId: root },
})
expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false)
expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false })
})
})
describe('remaining branches', () => {
it('refreshList folds a transport throw into the error state', async () => {
const api = new FakeApiClient()
@@ -409,9 +811,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 +845,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) }
@@ -583,6 +584,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: e610b990dd89204fd7e22e8b86f807d10b8ba439
README.zh.md: 268e05a806db1468ba689608c178646af132fe25

View File

@@ -12,8 +12,12 @@ 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)).
A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.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.
A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
@@ -30,9 +34,13 @@ 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.
`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 ordinary-session row remains a single-line preview with its exact-occurrence edit, delete, and strict-steer actions; addressed subagents retain the rows as a read-only projection because their continuation transport does not expose queue mutation. If strict steer loses to a closed window, the original occurrence remains queued for normal delivery; if the driver already claimed it, normal delivery is already underway. Neither converged race displays a failure, while transport and unknown failures do.
The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; Fork stays absent because the message has not entered a durable turn. The Host delays steering retirement until the durable `steering/message` has entered the mux stream. On that accepted live event, the client runtime retires the first matching current steering occurrence before publishing the snapshot; historical events cannot hide later occurrences that reuse the same `MessageId`. The bubble therefore hands off without a gap or duplicate, restores Copy and Fork from the durable node, and survives reconnect from the same authority.
Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the browser-persisted General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter remains a newline. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction.
Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks.
@@ -60,5 +68,5 @@ None; this package neither assembles nor sends a provider request.
- **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export.
- **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today.
- **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline.
- **Queue edit is text-only** — rows containing non-text blocks still show a flattened preview, but their edit control is disabled because the inline editor cannot preserve those blocks. A text row's edit mode replaces delete with save and cancel; Enter saves and Escape cancels. QueueDock exposes no send-now control.
- **Web exposes pending Queue only** — the composer and `conversation.send` never submit `mode:'steer'`. The Host omits pending steering from the Queue snapshot. A consumed `steering/message` still folds into the durable transcript as a plain bubble (no interjection chrome) so external/host steering remains truthful on replay.
- **Queue edit is text-only** — rows containing non-text blocks still show a flattened preview, but their edit control is disabled because the inline editor cannot preserve those blocks. A text row's edit mode replaces delete and strict steer with save and cancel; Enter saves and Escape cancels.
- **Queue strict steer preserves complete messages** — while the Agent is running, the steer action atomically transfers the addressed Queue occurrence into the current next-step window. Mixed-content rows remain eligible because the action forwards the immutable message instead of the text projection. The placement-aware Host snapshot renders pending steering at the conversation tail until the consumed `steering/message` folds into the durable transcript, so immediate display, reconnect, and replay share one linear authority.

View File

@@ -10,8 +10,12 @@
视图环本身就是 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))。
Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理吞吐:当 reasoning block 是流式尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整 reasoning 进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.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 变体的可展开源码渲染。
声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView``resultView` 对推导的唯一位置因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null落回通用路径。因此两个渲染点也都显示卡片的运行状态点它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`8面板为 16正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出按渲染意图开放——终端卡片与 web 卡片,各有自己的上限;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
@@ -30,9 +34,13 @@
审批经由本包声明的链接管编辑器:`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 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。每条可见行仍是单行预览,并提供针对精确单次入队项的编辑删除操作
`QueueDock``order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `"<n> 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded``aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。普通会话中的每条可见行仍是单行预览,并提供针对精确单次入队项的编辑删除和严格 steering中途引导操作已寻址 subagent 则保留只读行,因为其继续执行传输不提供 Queue 变更。如果严格 steering 输给已关闭的窗口,原单次入队项会留在 Queue 中正常投递;如果驱动器已经认领该项,正常投递就已开始。这两种已收敛的竞态都不显示失败,传输和未知错误仍会显示
Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;消息尚未进入持久轮次,因此不显示 fork。Host 会等持久 `steering/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会从持久节点恢复复制与 fork 操作,并能在重连后从同一权威恢复。
键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`Cmd/Ctrl+Enter 则执行另一种行为Shift+Enter 仍然换行。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 契约:如果当前 next-step 窗口在接纳前关闭AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。
逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store`stores.ts` `createChatStore`InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession``sessionId`、全局 `useSessions``useWorkspaces`,以及输入状态机的 `useInput``inputActions`store 表层与 inject factory 提供其余状态和回调。
@@ -60,5 +68,5 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
- **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。
- **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。
- **TodoPanel 将过长条目截成单行省略号**figma 条没有换行或展开入口,完整文本无法在行内读完。
- **Queue 编辑仅支持文本**:包含非文本块的行仍显示扁平化预览,但由于内联编辑器无法保留这些块,其编辑控件会被禁用。文本行进入编辑模式后,删除会替换为保存和取消Enter 保存Escape 取消。QueueDock 不提供立即发送控件。
- **Web 仅暴露待处理 Queue**composer 与 `conversation.send` 从不提交 `mode:'steer'`。Host 不会把待处理 steering中途引导纳入 Queue 快照。已消费的 `steering/message` 仍会折叠进持久 transcript文本记录并以无「插话」徽章的普通气泡呈现因此从外部Host 提交的 steering 在回放时仍能如实呈现
- **Queue 编辑仅支持文本**:包含非文本块的行仍显示扁平化预览,但由于内联编辑器无法保留这些块,其编辑控件会被禁用。文本行进入编辑模式后,删除和严格 steering中途引导操作会被保存和取消取代Enter 保存Escape 取消。
- **Queue 严格 steering 会保留完整消息**Agent 运行期间steering 操作会以原子方式把所寻址的 Queue 单次入队项转移到当前 next-step 窗口。包含混合内容的行仍可使用此操作,因为它会转发不可变消息,而非文本投影。带 placement 的 Host 快照会在会话流末尾渲染待处理 steering直到已消费的 `steering/message` 折叠进持久 transcript文本记录因此立即展示、重连和回放共享同一个线性权威

View File

@@ -1,6 +1,6 @@
/** Registers the conversation components, shared store, and service callbacks. */
import type { Context } from 'cordis'
import { resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
import { deferRegistration, resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
import type { ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
@@ -16,7 +16,10 @@ import { createChatStore } from './stores.ts'
import { ConversationService } from './service.ts'
import type { IConversation } from './service.ts'
import { InputHub } from './input/hub.ts'
import { ComposerSubmissionPolicy } from './input/submission-policy.ts'
import { InputBar } from './skeleton/InputBar.tsx'
import { EnterBehaviorRow } from './settings/EnterBehaviorRow.tsx'
import type { EnterBehaviorRowInjected } from './settings/EnterBehaviorRow.tsx'
import { ChatView } from './chat/ChatView.tsx'
import { StatsLine } from './chat/StatsLine.tsx'
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
@@ -93,6 +96,22 @@ export function apply(ctx: Context): void {
// Apply-time construction keeps store identity bound to this fiber.
const chatStore = createChatStore()
const submissionPolicy = new ComposerSubmissionPolicy()
ctx.effect(() => {
const row = deferRegistration(ctx.slots, 'settings.general.item', EnterBehaviorRow, () =>
ctx.slots.register({
name: 'settings.general.item',
id: 'composer-enter',
order: 20,
locale: NS,
inject: (): EnterBehaviorRowInjected => ({
hooks: { busyEnter: submissionPolicy.busyEnter },
setBusyEnter: (behavior) => { submissionPolicy.setBusyEnter(behavior) },
}),
}, EnterBehaviorRow))
return () => { row.dispose() }
}, 'ui-conversation: Enter behavior settings row')
// Chat scroll offsets by session, surviving view switches (the chat view
// unmounts under the tab ring). Deliberately not persisted: a fresh page
@@ -165,7 +184,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 +197,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)
@@ -198,6 +222,8 @@ export function apply(ctx: Context): void {
if (sessionId === undefined) {
return {
keyboard: undefined,
resolveSubmitMode: (running, gesture, steeringAvailable) =>
submissionPolicy.resolve(running, gesture, steeringAvailable),
toggleCommandMenu: undefined,
stop: undefined,
command: undefined,
@@ -208,6 +234,8 @@ export function apply(ctx: Context): void {
const slash = inputHub.slash(sessionId)
return {
keyboard: shell,
resolveSubmitMode: (running, gesture, steeringAvailable) =>
submissionPolicy.resolve(running, gesture, steeringAvailable),
toggleCommandMenu: slash === undefined
? undefined
: (selection) => {

View File

@@ -39,6 +39,13 @@ function firstLine(text: string): string {
return nl === -1 ? text : text.slice(0, nl)
}
/** Latest non-blank reasoning line while the block is still streaming. */
function latestLine(text: string): string {
const visible = text.trimEnd()
const nl = visible.lastIndexOf('\n')
return nl === -1 ? visible : visible.slice(nl + 1)
}
/** Joined text blocks for the copy action (reasoning / tool heads stay out). */
function copyText(blocks: readonly AssistantBlock[]): string {
const parts: string[] = []
@@ -61,7 +68,7 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass
variant="think"
icon={<IconThinkOutline14 size={14} />}
title="Think"
summary={firstLine(text)}
summary={running ? latestLine(text) : firstLine(text)}
body={text}
state={running ? 'running' : 'ok'}
/>

View File

@@ -34,7 +34,7 @@ import { assistantActionsSeqs, deriveChatFlow, type ChatFlowItem } from './chat-
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
import { GenericCommandCard } from './GenericCommandCard.tsx'
import { GenericToolCard } from './GenericToolCard.tsx'
import { MessageItem } from './MessageItem.tsx'
import { MessageItem, PendingSteeringBubble } from './MessageItem.tsx'
import css from './ChatView.module.css'
const FOLLOW_THRESHOLD = 24
@@ -236,6 +236,7 @@ export function ChatView({
useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t,
}: ChatViewSlotProps) {
const nodes = useSession(s => s.nodes)
const inbox = useSession(s => s.queue)
// Workspace root off the session list row: path summaries display relative to it.
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
const running = useSession(s => s.running)
@@ -248,6 +249,10 @@ export function ChatView({
const selectedCallId = useStore(s => s.selection?.callId)
const items = useMemo(() => deriveChatFlow(nodes), [nodes])
const pendingSteering = useMemo(
() => inbox.filter(item => item.placement === 'steering'),
[inbox],
)
const activeRetry = useMemo(() => activeRetrySeq(nodes, running), [nodes, running])
// Only the last content assistant of each turn owns IconActions; mid-turn
// text (before tools) omits `time` so AssistantMarkdown stays chrome-free.
@@ -261,6 +266,7 @@ export function ChatView({
const firstSeqRef = useRef<number | null>(null)
const openedRef = useRef(false)
const lastKeyRef = useRef<string | null>(null)
const lastSteeringIdRef = useRef<string | null>(null)
/** Flow tip signature — follow-scroll only when this moves, never on a
* scroll-driven at-bottom chrome re-render (that was snapping inertial
* scrolls the rest of the way to the floor). */
@@ -269,7 +275,8 @@ export function ChatView({
const firstSeq = nodes[0]?.seq ?? null
const lastItem = items[items.length - 1]
const lastKey = lastItem?.key ?? null
const followSig = `${openState}:${firstSeq}:${lastKey}:${nodes.length}:${running ? 1 : 0}:${runningCalls.length}`
const lastSteeringId = pendingSteering[pendingSteering.length - 1]?.id ?? null
const followSig = `${openState}:${firstSeq}:${lastKey}:${nodes.length}:${running ? 1 : 0}:${runningCalls.length}:${lastSteeringId ?? ''}`
const toBottom = (el: HTMLElement): void => {
el.scrollTop = el.scrollHeight
@@ -298,6 +305,7 @@ export function ChatView({
}
firstSeqRef.current = firstSeq
lastKeyRef.current = lastKey
lastSteeringIdRef.current = lastSteeringId
followSigRef.current = followSig
return
}
@@ -308,6 +316,7 @@ export function ChatView({
firstSeqRef.current = firstSeq
/* v8 ignore next -- ?? arm: a prepend adds nodes, so the flow list here is never empty. */
lastKeyRef.current = lastKey
lastSteeringIdRef.current = lastSteeringId
followSigRef.current = followSig
return
}
@@ -316,12 +325,14 @@ export function ChatView({
// (send lives in the composer, so arrival is detected here, not armed there).
const appendedUser = lastKey !== lastKeyRef.current
&& lastItem !== undefined && lastItem.kind === 'node' && lastItem.node.kind === 'user'
const appendedSteering = lastSteeringId !== null && lastSteeringId !== lastSteeringIdRef.current
const tipMoved = followSigRef.current !== followSig
lastKeyRef.current = lastKey
lastSteeringIdRef.current = lastSteeringId
followSigRef.current = followSig
// Follow new flow content while pinned; do NOT re-pin on every render
// merely because atBottomRef is true (scroll threshold → setState → snap).
if (appendedUser || (tipMoved && atBottomRef.current)) toBottom(el)
if (appendedUser || appendedSteering || (tipMoved && atBottomRef.current)) toBottom(el)
})
const onScrollRef = useRef(() => {})
@@ -467,6 +478,9 @@ export function ChatView({
{/* Turn-level loading signal: rides the whole running turn (first-token
wait, tool execution, streaming) so it never flickers per step. */}
{running && <TurnStatus />}
{pendingSteering.map(item => (
<PendingSteeringBubble key={item.id} content={item.content} t={t} />
))}
</div>
{!atBottom && (
<div className={css.toBottomSlot}>

View File

@@ -1,5 +1,5 @@
// Shared IconActions chrome for user and assistant messages: copy live,
// branch wired through onBranch, date-aware clock.
// Shared IconActions chrome for user, steering, and assistant messages: copy
// live, optional branch wiring, and an optional date-aware clock.
import { useCallback } from 'react'
import {
@@ -13,12 +13,14 @@ import css from './MessageIconActions.module.css'
export interface MessageIconActionsProps {
/** Plain text the copy action writes. */
text: string
/** Unix epoch ms for the clock label. */
time: number
/** Unix epoch ms for the clock label; omitted for transient messages. */
time?: number | undefined
/** Clock before icons (user) or after (assistant). */
clock: 'start' | 'end'
/** Fork the session at this message. */
onBranch?: (() => void) | undefined
/** Whether to render the branch action; defaults to true. */
showBranch?: boolean | undefined
/** Parent layout class composed onto the actions row. */
className?: string | undefined
/** The owning view's locale seat, passed down as a plain prop. */
@@ -31,13 +33,13 @@ export interface MessageIconActionsProps {
* @returns The actions row element.
*/
export function MessageIconActions({
text, time, clock, onBranch, className, t,
text, time, clock, onBranch, showBranch = true, className, t,
}: MessageIconActionsProps) {
const day = useCalendarDay()
const onCopy = useCallback(() => {
void writeClipboard(text)
}, [text])
const clockEl = (
const clockEl = time === undefined ? null : (
<span className={clock === 'start' ? css.timeStart : css.timeEnd}>
{formatMessageClock(time, t, day)}
</span>
@@ -50,11 +52,13 @@ export function MessageIconActions({
<IconCopyOutline16 />
</button>
</Tooltip>
<Tooltip label={t('message.branch')} side="bottom">
<button type="button" className={css.action} aria-label={t('message.branch')} onClick={onBranch}>
<IconBranchOutline16 />
</button>
</Tooltip>
{showBranch && (
<Tooltip label={t('message.branch')} side="bottom">
<button type="button" className={css.action} aria-label={t('message.branch')} onClick={onBranch}>
<IconBranchOutline16 />
</button>
</Tooltip>
)}
{clock === 'end' ? clockEl : null}
</div>
)

View File

@@ -1,6 +1,6 @@
// MessageItem: simple chat nodes — user bubble (right-aligned, with
// clock + copy / branch IconActions), steering (same bubble, no actions),
// context injection, compaction marker, retry disclosure, and
// MessageItem: simple chat nodes — user and consumed-steering bubbles
// (right-aligned, with clock + copy / branch IconActions), pending steering
// (copy only), context injection, compaction marker, retry disclosure, and
// unknown-surface JSON rows.
import { memo, useEffect, useMemo, useState } from 'react'
@@ -167,19 +167,21 @@ function projectUserText(text: string): ReactNode {
return <>{parts}</>
}
/** Right-aligned bubble shared by user and steering rows (steering has no actions). */
/** Right-aligned bubble shared by user and steering rows. */
function UserStyleBubble({
content, actions, t,
content, actions, pending = false, t,
}: {
content: readonly unknown[]
/** Optional IconActions (or similar) below the bubble; receives the joined text. */
actions?: (text: string) => ReactNode
/** Whether this is the Host-authoritative pre-admission steering projection. */
pending?: boolean
t: ChatViewSlotProps['t']
}): ReactNode {
const { text, rest } = contentText(content)
const truncated = (total: number): string => t('json.truncated', { total })
return (
<div className={css.userRow}>
<div className={css.userRow} data-pending-steering={pending || undefined}>
<div className={css.bubble}>
{projectUserText(text)}
{rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}
@@ -189,12 +191,41 @@ function UserStyleBubble({
)
}
/**
* Render one Host-authoritative pending steering item with the same visual
* language as its eventual durable transcript node.
* @param props - Pending message content and conversation translator.
* @returns the pending steering bubble.
*/
export function PendingSteeringBubble({ content, t }: {
content: readonly unknown[]
t: ChatViewSlotProps['t']
}): ReactNode {
return (
<UserStyleBubble
content={content}
pending
t={t}
actions={text => (
<MessageIconActions
text={text}
clock="start"
showBranch={false}
className={css.actions}
t={t}
/>
)}
/>
)
}
export const MessageItem = memo(function MessageItem({
node, retryActive = false, onFork, t,
}: MessageItemProps) {
const truncated = (total: number): string => t('json.truncated', { total })
switch (node.kind) {
case 'user':
case 'steering':
return (
<UserStyleBubble
content={node.content}
@@ -211,8 +242,6 @@ export const MessageItem = memo(function MessageItem({
)}
/>
)
case 'steering':
return <UserStyleBubble content={node.content} t={t} />
case 'context':
return (
<ContextInjectionRow content={node.content} source={node.source} t={t} />

View File

@@ -84,6 +84,11 @@
color: var(--dsw-alias-label-tertiary);
}
/* Live reasoning follows its one-line summary to the inline end. */
.summary[data-follow-end] {
text-overflow: clip;
}
/* File-tool path: same geometry as .summary; hover underline + pointer. */
.fileLink {
flex: 1 1 auto;

View File

@@ -5,7 +5,8 @@
// Enter / Space, icon→chevron hover preview). The collapsed row is always
// one line; every row with body, output, or a card material (terminal, diff,
// read, search, web) is expandable; the summary stays inline while open,
// except Think, whose body opens with the same first line and would repeat it.
// except Think, where the running collapsed row follows the latest line at its
// scroll end and the summary yields while open to avoid repeating the body.
// The expanded body — an IN/OUT gutter-labeled card (figma 1249:35657) for
// text input/output, the run_code program through CodeBlock, or a card
// primitive (TerminalBlock, DiffBlock, ReadBlock, SearchBlock, WebBlock) for a
@@ -19,7 +20,7 @@
// independent); an error row's collapsed summary is the failure's first line in
// the error color.
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import { useLayoutEffect, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import {
CodeBlock, DiffBlock, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock,
@@ -152,6 +153,7 @@ export function ToolRow({
inspect,
}: ToolRowProps) {
const [expanded, setExpanded] = useState(false)
const summaryRef = useRef<HTMLSpanElement>(null)
const terminalBody = terminal ?? null
const diffBody = diff ?? null
const readBody = read ?? null
@@ -173,6 +175,15 @@ export function ToolRow({
const summaryText = failureLine ?? summary
// The failure line is error prose, not the path: no open-file affordance.
const fileLink = filePath !== undefined && onOpenFile !== undefined && failureLine === null
const isThink = variant === 'think'
const followSummaryEnd = isThink && state === 'running' && !open
useLayoutEffect(() => {
const summaryElement = summaryRef.current
if (summaryElement === null) return
summaryElement.scrollLeft = followSummaryEnd
? summaryElement.scrollWidth - summaryElement.clientWidth
: 0
}, [followSummaryEnd, summaryText])
const toggleExpand = () => {
setExpanded(v => !v)
}
@@ -188,9 +199,8 @@ export function ToolRow({
if (event.key === 'Enter' || event.key === ' ') event.stopPropagation()
}
// Think reasoning is prose, not an input payload: expanded, it renders as
// plain indented text (no IN/OUT card) and the inline summary — the body's
// own first line — yields to avoid repeating itself.
const isThink = variant === 'think'
// plain indented text (no IN/OUT card) and the inline summary yields to avoid
// repeating the body.
// The code variant's program renders through CodeBlock (shiki), so only its
// output joins the IN/OUT card; every other variant's input does too.
const cardBody = variant === 'code' ? null : body
@@ -227,7 +237,11 @@ export function ToolRow({
{summaryText}
</button>
) : (
<span className={clsx(css.summary, failureLine !== null && css.errorSummary)}>
<span
ref={isThink ? summaryRef : undefined}
className={clsx(css.summary, failureLine !== null && css.errorSummary)}
data-follow-end={followSummaryEnd || undefined}
>
{summaryText}
</span>
)}

View File

@@ -0,0 +1,10 @@
/** Composer submission vocabulary shared by the input and settings domains. */
/** Delivery mode requested for one ordinary composer message. */
export type InputSubmitMode = 'queue' | 'steer'
/** Configurable meaning of plain Enter while the addressed agent is busy. */
export type BusyEnterBehavior = InputSubmitMode
/** Keyboard gesture whose delivery mode the submission policy resolves. */
export type ComposerSubmitGesture = 'enter' | 'accelerated'

View File

@@ -3,10 +3,11 @@ 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'
import type { ComposerSubmitGesture, InputSubmitMode } from './composer-submission.ts'
import type { CallId, SelectionTarget, ViewTab } from './views.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
@@ -17,6 +18,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 +138,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 +250,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
}
/**
@@ -278,6 +286,12 @@ export interface ComposerBarOwnerProps {
export interface ComposerBarInjected {
/** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane); absent with the session. */
keyboard: ComposerKeyboard | undefined
/** Resolve one keyboard submission gesture against the current running state and persisted preference. */
resolveSubmitMode: (
running: boolean,
gesture: ComposerSubmitGesture,
steeringAvailable: boolean,
) => InputSubmitMode
/** Toggle the shared slash menu with only its command source; absent without ui-slash or a session. */
toggleCommandMenu: ((selection: EditSelection) => void) | undefined
/** Cancel the in-flight turn; absent with the session. */
@@ -329,6 +343,8 @@ export type ComposerBarProps =
*/
export interface ComposerChainProps {
interactions: readonly PendingInteraction[]
/** Current conversation facts for feature-owned takeover selectors. */
session: ConversationSnapshot | undefined
}
/**
@@ -350,9 +366,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

@@ -16,6 +16,7 @@ import type {
EditRange, EditSelection, InputActions, InputEffect, InputNotice, InputState,
PasteComponent, QueuedMessage, SessionInput, SubmitAttempt,
} from './contract.ts'
import type { InputSubmitMode } from '../contract/composer-submission.ts'
import { InputMachine } from './machine.ts'
/** Popup face the shell needs (dismissal only; typed structurally to avoid a value import). */
@@ -39,7 +40,7 @@ export interface SessionInputDeps {
/** Queue read face; overlaid onto InputState.queue (absent = empty). */
queue?: ObservableSnapshot<readonly QueuedMessage[]> | undefined
/** The plain-message sink (send choreography / materialize fork — the hub owns it). */
defaultSink(text: string): void
defaultSink(text: string, mode: InputSubmitMode): void
}
/** Guard tier from the machine phase. */
@@ -68,7 +69,7 @@ export class SessionInputShell implements SessionInput {
/** The public provide-channel action face (one stable identity per session — decision 20). */
readonly actions: InputActions = {
setDraft: (text) => { this.setDraft(text) },
submit: () => { this.submit() },
submit: () => { this.submit('queue') },
}
// Real wall clock: the typing-run merge window must actually expire in
@@ -106,15 +107,6 @@ export class SessionInputShell implements SessionInput {
this.run(this.core.dispatch({ type: 'send-committed' }))
}
/**
* Insert a newline at the selection as one machine transaction (the
* execCommand path is gone — a second undo history would fork).
* @param selection - current DOM selection in draft coordinates.
*/
newline(selection: EditSelection): void {
this.run(this.core.dispatch({ type: 'newline', selection }))
}
/** Undo the latest transaction (InputBar intercepts the platform chord). */
undo(): void {
this.run(this.core.dispatch({ type: 'undo' }))
@@ -152,8 +144,8 @@ export class SessionInputShell implements SessionInput {
* (adjudicating/submitting) force-closes the transient layers: the popup
* dismisses and the menu tracks frozen.
*/
submit(): void {
this.run(this.core.dispatch({ type: 'enter' }))
submit(mode: InputSubmitMode = 'queue'): void {
this.run(this.core.dispatch({ type: 'enter', mode }))
const phase = this.snapshot.phase
if (phase === 'adjudicating' || phase === 'submitting') {
this.deps.popup?.()?.dismiss()
@@ -338,7 +330,7 @@ export class SessionInputShell implements SessionInput {
return
}
case 'default-sink': {
this.sinkSerialized(fx.draft)
this.sinkSerialized(fx.draft, fx.mode)
return
}
default:
@@ -353,10 +345,10 @@ export class SessionInputShell implements SessionInput {
* send — notice + draft and chips retained, never a silent downgrade to
* the clipboard text. Chip-free drafts skip the async detour.
*/
private sinkSerialized(draft: string): void {
private sinkSerialized(draft: string, mode: InputSubmitMode): void {
const occurrences = this.core.state.occurrences
if (occurrences.length === 0) {
this.deps.defaultSink(draft.trim())
this.deps.defaultSink(draft.trim(), mode)
return
}
const slash = this.deps.slash?.()
@@ -376,7 +368,7 @@ export class SessionInputShell implements SessionInput {
cursor = part.offset + 1
}
out += draft.slice(cursor)
this.deps.defaultSink(out.trim())
this.deps.defaultSink(out.trim(), mode)
},
(error: unknown) => {
controller.abort()

View File

@@ -12,6 +12,7 @@ import type { ClientContext, ISessions, SessionBinding, SessionFace, SessionId }
import type { SlashController } from '@deepseek-ai/dsh-client-ui-slash/client'
import { queueReadFaceOf } from '../queue/store.ts'
import type { ComposerKeyboard, InputService, SessionInput } from './contract.ts'
import type { InputSubmitMode } from '../contract/composer-submission.ts'
import type { PopupDismissFace } from './facade.ts'
import { SessionInputShell } from './facade.ts'
@@ -56,7 +57,7 @@ export class InputHub implements InputService {
slash: () => this.controller(actx),
popup: () => this.popup(actx),
queue: queueReadFaceOf(session),
defaultSink: (text) => { this.sink(session, text) },
defaultSink: (text, mode) => { this.sink(session, text, mode) },
})
this.shells.set(id, shell)
// The one teardown axis: listeners, shell, and map entries all ride the
@@ -123,12 +124,12 @@ export class InputHub implements InputService {
* exactly one path; a failed first prompt is an ordinary prompt failure
* (error strip via promptError, draft restored only while untouched).
*/
private sink(session: SessionFace, text: string): void {
private sink(session: SessionFace, text: string, mode: InputSubmitMode): void {
if (text === '') return
const shell = this.shells.get(session.sessionId)
// Commit, not an editable clear: undo must not resurrect sent content.
shell?.commitSend()
void session.prompt([{ type: 'text', text }], 'queue').then(
void session.prompt([{ type: 'text', text }], mode).then(
(result) => {
if (!result.ok && shell?.snapshot.draft === '') shell.setDraft(text)
},

View File

@@ -14,6 +14,7 @@
* paste-upgrade all answer their bail events this way).
*/
import type { CommandClaim, ReferenceInsert, TokenSpan } from '@deepseek-ai/dsh-client-ui-slash/client'
import type { InputSubmitMode } from '../contract/composer-submission.ts'
import type {
ConsumeTokenGuard, EditRange, EditSelection, InputEffect, InputEvent, InputMachineOptions,
InputState, Occurrence, PasteAttemptState, PasteComponent, SubmitAttempt,
@@ -149,7 +150,6 @@ export class InputMachine {
dispatch(ev: InputEvent): readonly InputEffect[] {
switch (ev.type) {
case 'draft-changed': return this.onDraftChanged(ev.draft, ev.editRange)
case 'newline': return this.onNewline(ev.selection)
case 'begin-command': return this.onBeginCommand(ev.claim, ev.span)
case 'insert-ref': return this.onInsertRef(ev.reference, ev.span)
case 'consume-token': return this.onConsumeToken(ev.guard)
@@ -162,7 +162,7 @@ export class InputMachine {
this.paste = undefined
return []
}
case 'enter': return this.onEnter()
case 'enter': return this.onEnter(ev.mode)
case 'adjudicated': return this.onAdjudicated(ev.attempt, ev.outcome)
case 'adjudication-failed': return this.onAdjudicationFailed(ev.attempt, ev.message)
case 'submit-settled': return this.onSubmitSettled(ev)
@@ -254,19 +254,6 @@ export class InputMachine {
return []
}
/** F1: caret newline as an ordinary machine transaction (execCommand path removed). */
private onNewline(selection: EditSelection): InputEffect[] {
const { start, end } = selection
if (start < 0 || start > end || end > this.draft.length) return []
this.pushTxn(selection)
this.typingRun = undefined
this.reconcile({ start, end, insertedLength: 1 })
this.adopt(this.draft.slice(0, start) + '\n' + this.draft.slice(end))
this.watchClaim()
this.paste = undefined
return []
}
/** Span CAS: revision equality (content identity follows) plus bounds sanity. */
private casOk(span: TokenSpan): boolean {
return span.draftRev === this.draftRev
@@ -461,18 +448,18 @@ export class InputMachine {
// ---- submit plane ----
/** Mint the next SubmitAttempt and take the in-flight slot. */
private beginAttempt(): SubmitAttempt {
private beginAttempt(mode: InputSubmitMode): SubmitAttempt {
const controller = new AbortController()
this.seq += 1
const attempt: SubmitAttempt = { seq: this.seq, signal: controller.signal, draftSnapshot: this.draft }
const attempt: SubmitAttempt = { seq: this.seq, signal: controller.signal, draftSnapshot: this.draft, mode }
this.inflight = { attempt, controller }
return attempt
}
private onEnter(): InputEffect[] {
private onEnter(mode: InputSubmitMode): InputEffect[] {
if (this.phase === 'adjudicating' || this.phase === 'submitting') return []
if (this.phase === 'claimed' && this.claim !== undefined) {
const attempt = this.beginAttempt()
const attempt = this.beginAttempt(mode)
this.phase = 'submitting'
this.paste = undefined
return [{ type: 'begin-submit', attempt, claim: this.claim, args: argsAfter(this.draft, this.claim.token) }]
@@ -481,11 +468,11 @@ export class InputMachine {
if (trimmed === '') return []
this.paste = undefined
if (trimmed.startsWith('/')) {
const attempt = this.beginAttempt()
const attempt = this.beginAttempt(mode)
this.phase = 'adjudicating'
return [{ type: 'adjudicate', attempt, draft: this.draft }]
}
return [{ type: 'default-sink', draft: this.draft }]
return [{ type: 'default-sink', draft: this.draft, mode }]
}
private onAdjudicated(attempt: SubmitAttempt, outcome: Extract<InputEvent, { type: 'adjudicated' }>['outcome']): InputEffect[] {
@@ -506,7 +493,7 @@ export class InputMachine {
this.inflight = undefined
this.phase = 'plain'
return outcome === undefined
? [{ type: 'default-sink', draft: attempt.draftSnapshot }]
? [{ type: 'default-sink', draft: attempt.draftSnapshot, mode: attempt.mode }]
: []
}

View File

@@ -0,0 +1,77 @@
/**
* Browser-local Composer submission policy. It owns the persisted busy-Enter
* preference and resolves keyboard gestures into queue/steer delivery modes;
* Host and Agent keep the actual delivery-window authority.
*/
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type {
BusyEnterBehavior, ComposerSubmitGesture, InputSubmitMode,
} from '../contract/composer-submission.ts'
/** localStorage key holding the busy-Enter preference. */
export const BUSY_ENTER_STORAGE_KEY = 'dsh.conversation.busyEnter'
/** Default preserves Enter-as-Queue for running conversations. */
export const DEFAULT_BUSY_ENTER_BEHAVIOR: BusyEnterBehavior = 'queue'
/**
* Persisted policy used by both the composer inject face and its Settings row.
* Direct `steer` is intentionally best-effort: AgentLoop turns a closed-window
* submission into the next waking Queue item.
*/
export class ComposerSubmissionPolicy {
/** Reactive preference source for the Settings row. */
readonly busyEnter: SnapshotStore<BusyEnterBehavior> = createSnapshotStore(restoreBusyEnter())
/**
* Resolve one keyboard gesture without changing state.
* @param running - whether the addressed agent currently reports busy.
* @param gesture - plain Enter or the Cmd/Ctrl-accelerated chord.
* @param steeringAvailable - whether this session transport supports steering.
* @returns Queue outside steer-capable busy state; otherwise the preferred mode or its opposite.
*/
resolve(
running: boolean,
gesture: ComposerSubmitGesture,
steeringAvailable: boolean,
): InputSubmitMode {
if (!running || !steeringAvailable) return 'queue'
const preferred = this.busyEnter.getSnapshot()
if (gesture === 'enter') return preferred
return preferred === 'queue' ? 'steer' : 'queue'
}
/**
* Change and persist the plain-Enter behavior used during busy state.
* @param behavior - Queue or Steer.
*/
setBusyEnter(behavior: BusyEnterBehavior): void {
if (this.busyEnter.getSnapshot() === behavior) return
this.busyEnter.set(behavior)
persistBusyEnter(behavior)
}
}
/** Restore a valid preference; unavailable or corrupt storage uses Queue. */
function restoreBusyEnter(): BusyEnterBehavior {
if (typeof localStorage === 'undefined') return DEFAULT_BUSY_ENTER_BEHAVIOR
let stored: string | null
try {
stored = localStorage.getItem(BUSY_ENTER_STORAGE_KEY)
} catch {
// Storage access can fail in privacy modes; the default remains usable.
return DEFAULT_BUSY_ENTER_BEHAVIOR
}
if (stored === 'queue' || stored === 'steer') return stored
return DEFAULT_BUSY_ENTER_BEHAVIOR
}
/** Persist a preference when browser storage is available. */
function persistBusyEnter(behavior: BusyEnterBehavior): void {
if (typeof localStorage === 'undefined') return
try {
localStorage.setItem(BUSY_ENTER_STORAGE_KEY, behavior)
} catch {
// A storage failure makes the preference session-only; input stays usable.
}
}

View File

@@ -23,6 +23,10 @@ export const zh = {
'input.stop': '停止生成',
'input.send': '发送消息',
'input.accessMode': '访问模式,当前:{name}',
'settings.enter.title': '繁忙时 Enter 键行为',
'settings.enter.description': '仅在智能体运行时生效Cmd/Ctrl+Enter 使用另一行为',
'settings.enter.queue': '排队发送',
'settings.enter.steer': '插话发送',
'access.confirm.title': '确认启用 Full access',
'access.confirm.description': '启用 Full access 后agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。',
'access.confirm.acknowledge': '我已了解风险,并愿意继续',
@@ -30,6 +34,7 @@ export const zh = {
'access.confirm.enable': '启用 Full access',
'hero.headline': '开始构建吧',
'hero.chooseWorkspace': '选择工作区',
'session.hierarchy': '会话层级',
'details.title': '详情',
'details.close': '关闭详情',
'details.empty': '点击消息流中的工具行查看详情',
@@ -88,8 +93,11 @@ export const zh = {
'queue.save': '保存排队消息',
'queue.cancelEdit': '取消编辑',
'queue.remove': '删除排队消息',
'queue.steer': '插话发送',
'queue.steer.unavailable': '仅运行中可插话发送',
'queue.editFailed': '编辑失败:这条消息可能已经开始发送。',
'queue.removeFailed': '删除失败:这条消息可能已经开始发送。',
'queue.steerFailed': '插话发送失败,请重试。',
'terminal.signal': '信号 {signal}',
'terminal.exitCode': '退出码 {code}',
'terminal.running': '运行中',
@@ -122,6 +130,10 @@ export const en = {
'input.stop': 'Stop generating',
'input.send': 'Send message',
'input.accessMode': 'Access mode, current: {name}',
'settings.enter.title': 'Enter behavior while busy',
'settings.enter.description': 'Busy only; Cmd/Ctrl+Enter uses the other behavior',
'settings.enter.queue': 'Queue',
'settings.enter.steer': 'Steer',
'access.confirm.title': 'Enable Full access?',
'access.confirm.description': 'Full access reduces confirmation steps and lets the agent perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust the current task.',
'access.confirm.acknowledge': 'I understand the risks and want to continue',
@@ -129,6 +141,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',
@@ -187,8 +200,11 @@ export const en = {
'queue.save': 'Save queued message',
'queue.cancelEdit': 'Cancel editing',
'queue.remove': 'Remove queued message',
'queue.steer': 'Steer queued message',
'queue.steer.unavailable': 'Steering is available only while the agent is running',
'queue.editFailed': 'Edit failed: this message may have already started sending.',
'queue.removeFailed': 'Removal failed: this message may have already started sending.',
'queue.steerFailed': 'Steering failed. Try again.',
'terminal.signal': 'signal {signal}',
'terminal.exitCode': 'exit code {code}',
'terminal.running': 'Running',

View File

@@ -4,12 +4,12 @@
// The 'conversation.input.dock' SlotMap declaration lives in
// ../contract/slots.ts beside the other input-region slots.
import type { Context } from 'cordis'
import { useEffect, useId, useState } from 'react'
import { useEffect, useId, useMemo, useState } from 'react'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import {
IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14,
IconCloseOutline16, IconEditOutline16, IconTrashOutline16,
IconCloseOutline16, IconEditOutline16, IconSendOutline16, IconTrashOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { QueueAction, QueueItemId } from '../contract/queue.ts'
import { NS } from '../locales.ts'
@@ -29,7 +29,10 @@ export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDock
* collapsible count header; an empty queue renders nothing.
*/
export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps) {
const queue = useSession(s => s.queue)
const inbox = useSession(s => s.queue)
const queue = useMemo(() => inbox.filter(row => row.placement === 'queued'), [inbox])
const running = useSession(s => s.running)
const queueMutable = useSession(s => s.subagent === null)
const [editing, setEditing] = useState<{ id: QueueItemId; text: string } | null>(null)
const [busy, setBusy] = useState<QueueItemId | null>(null)
const [collapsed, setCollapsed] = useState(true)
@@ -37,12 +40,12 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
useEffect(() => {
if (queue.length === 0 && !collapsed) setCollapsed(true)
if (editing !== null && !queue.some(row => row.id === editing.id)) setEditing(null)
}, [collapsed, editing, queue])
if (editing !== null && (!queueMutable || !queue.some(row => row.id === editing.id))) setEditing(null)
}, [collapsed, editing, queue, queueMutable])
if (queue.length === 0) return null
const interactionActive = editing !== null || busy !== null
const interactionActive = queueMutable && (editing !== null || busy !== null)
const expanded = !collapsed || interactionActive
const listVisible = queue.length === 1 || expanded
@@ -114,7 +117,7 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
/>
)
: <span className={css.preview}>{row.preview}</span>}
<div className={css.actions}>
{queueMutable && <div className={css.actions}>
{editing?.id === row.id
? (
<>
@@ -170,9 +173,25 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
>
<IconTrashOutline16 size={14} />
</button>
<button
type="button"
className={css.action}
aria-label={t('queue.steer')}
title={running ? t('queue.steer') : t('queue.steer.unavailable')}
disabled={busy !== null || !running}
onClick={() => {
void applyAction(
row.id,
{ kind: 'steer' },
t('queue.steerFailed'),
)
}}
>
<IconSendOutline16 size={14} />
</button>
</>
)}
</div>
</div>}
</li>
))}
</ul>

View File

@@ -1,7 +1,7 @@
/**
* Queue read face for the InputState.queue projection (frozen contract in
* ../input/contract.ts): a uSES-compatible observable over one session's
* queue rows. The Session snapshot already keeps the queue array
* transient inbox rows. The Session snapshot already keeps the queue array
* reference-stable across unrelated snapshot swaps, so this is a pure
* projection — no second store, no copy.
*/
@@ -9,7 +9,7 @@ import type { ObservableSnapshot, SessionFace } from '@deepseek-ai/dsh-client-ru
import type { QueuedMessage } from '../input/contract.ts'
/**
* Project a session's queue rows as a bare observable (subscribe/getSnapshot).
* Project a session's transient inbox rows as a bare observable (subscribe/getSnapshot).
* The wiring layer (T5) overlays this onto InputState.queue; the runtime
* QueuedMessage and the input-contract QueuedMessage are structurally
* identical.

View File

@@ -31,10 +31,10 @@ export interface IConversation {
*/
send(text: string): Promise<void>
/**
* Apply one operation to a pending queue occurrence.
* Apply one edit, remove, or strict steer operation to a pending queue occurrence.
* @param itemId - agent-owned inbox occurrence identity.
* @param action - edit or remove operation.
* @returns completion; business failures reject.
* @param action - requested queue operation.
* @returns completion; converged strict-steer races resolve, while other failures reject.
*/
updateQueue(itemId: QueueItemId, action: QueueAction): Promise<void>
/**
@@ -82,6 +82,10 @@ export class ConversationService extends Service implements IConversation {
const session = this.scopedSession('updateQueue')
const result = await session.updateQueue(itemId, action)
if (!result.ok) {
if (
action.kind === 'steer'
&& (result.error.code === 'steer-unavailable' || result.error.code === 'queue-item-not-found')
) return
throw new Error(`conversation.updateQueue failed: ${result.error.code}: ${result.error.message}`)
}
}

View File

@@ -0,0 +1,56 @@
/* Composer Enter preference row: title/description plus selector pill. */
.row {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 0;
border-bottom: 1px solid var(--dsw-alias-border-l2);
}
.rowText {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
padding-right: 48px;
}
.title {
font-size: 14px;
font-weight: 400;
line-height: 22px;
color: var(--dsw-alias-label-primary);
}
.desc {
font-size: 12px;
font-weight: 400;
line-height: 18px;
color: var(--dsw-alias-label-tertiary);
}
.selector {
display: inline-flex;
align-items: center;
gap: 12px;
height: 36px;
padding: 0 14px;
border: none;
border-radius: 18px;
background: var(--dsw-alias-bg-module-platform);
font: inherit;
font-size: 14px;
line-height: 22px;
color: var(--dsw-alias-label-primary);
cursor: pointer;
}
.selector:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.chevron {
flex: none;
}

View File

@@ -0,0 +1,76 @@
/** General Settings row for the Composer's busy-state Enter preference. */
import { useState } from 'react'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
import type { BusyEnterBehavior } from '../contract/composer-submission.ts'
import type { ConversationKey } from '../locales.ts'
import css from './EnterBehaviorRow.module.css'
/** Registration-side preference face. */
export interface EnterBehaviorRowInjected {
hooks: {
/** Persisted busy-state preference bound as useBusyEnter. */
busyEnter: SnapshotStore<BusyEnterBehavior>
}
/** Change the busy-state plain-Enter behavior. */
setBusyEnter: (behavior: BusyEnterBehavior) => void
}
/** Full Settings-row props. */
export type EnterBehaviorRowProps =
PropsRuntime<'settings.general.item'>
& PropsLocale<'conversation'>
& InjectFace<EnterBehaviorRowInjected>
const OPTIONS: readonly {
id: BusyEnterBehavior
label: ConversationKey
}[] = [
{ id: 'queue', label: 'settings.enter.queue' },
{ id: 'steer', label: 'settings.enter.steer' },
]
/**
* Render the busy-state Enter behavior selector.
* @param props - composed Settings slot props.
* @returns the preference row.
*/
export function EnterBehaviorRow({ useBusyEnter, setBusyEnter, t }: EnterBehaviorRowProps) {
const behavior = useBusyEnter(value => value)
const [open, setOpen] = useState(false)
const selectedLabel = behavior === 'queue' ? 'settings.enter.queue' : 'settings.enter.steer'
return (
<div className={css.row}>
<div className={css.rowText}>
<div className={css.title}>{t('settings.enter.title')}</div>
<div className={css.desc}>{t('settings.enter.description')}</div>
</div>
<Menu
open={open}
onClose={() => { setOpen(false) }}
items={OPTIONS.map(option => ({ id: option.id, label: t(option.label) }))}
selectedId={behavior}
onSelect={(id) => {
setOpen(false)
setBusyEnter(id as BusyEnterBehavior)
}}
align="end"
portal
anchor={(
<button
type="button"
className={css.selector}
aria-haspopup="menu"
aria-expanded={open}
onClick={() => { setOpen(value => !value) }}
>
{t(selectedLabel)}
<IconChevronDownOutline14 className={css.chevron} />
</button>
)}
/>
</div>
)
}

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

@@ -34,7 +34,7 @@ export interface InputBarError {
export type InputBarProps = ComposerBarProps
export function InputBar({
useSession, useInput, inputActions, keyboard, toggleCommandMenu, stop, command, t,
useSession, useInput, inputActions, keyboard, resolveSubmitMode, toggleCommandMenu, stop, command, t,
renderSlot, useNotices, useLexicon, useMenuLauncher,
useProjection, sessionId, variant, disabled: inert = false, placeholder, accessory, overlay, leftItems, rightItems, footer,
}: InputBarProps) {
@@ -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).
@@ -177,23 +178,14 @@ export function InputBar({
e.preventDefault()
return
}
if (e.ctrlKey || e.metaKey) {
// Newline as a machine transaction (the machine owns undo history; an
// execCommand write would fork a second, browser-owned history).
e.preventDefault()
if (!machineBusy && !locked) {
const el = e.currentTarget
const sel = selectionOf(el)
keyboard.newline(sel)
const caret = sel.start + 1
requestAnimationFrame(() => { el.setSelectionRange(caret, caret) })
}
return
}
e.preventDefault()
if (e.repeat) return // held-down Enter must not machine-gun sends
if (locked || machineBusy) return
inputActions.submit()
keyboard.submit(resolveSubmitMode(
running,
e.ctrlKey || e.metaKey ? 'accelerated' : 'enter',
subagent === null,
))
}
const onChange = (e: ChangeEvent<HTMLTextAreaElement>): void => {
@@ -283,13 +275,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 +459,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

@@ -37,6 +37,7 @@ async function bench() {
await runtime.root.declare({
'conversation': { kind: 'single', scope: 'session-maybe' },
'details': { kind: 'single', scope: 'session' },
'settings.general.item': { kind: 'list', scope: 'root' },
}, (_p: { renderSlot?: unknown }) => null)
const feature = await runtime.mount({ inject: [...inject], apply })
@@ -85,6 +86,7 @@ describe('apply wiring', () => {
// The hero workspace picker hole rides the conversation entry's children
// declaration (the empty-state occupant is gone).
expect(b.slots.spec('conversation.hero.workspace')).toEqual({ kind: 'single', scope: 'root' })
expect(b.slots.entries('settings.general.item').map(entry => entry.options.id)).toEqual(['composer-enter'])
await b.runtime.dispose()
})
@@ -112,6 +114,7 @@ describe('apply wiring', () => {
expect(b.slots.entries('conversation.chat.toolview')).toHaveLength(0)
expect(b.slots.spec('conversation.chat.toolview')).toBeUndefined()
expect(b.slots.entries('details')).toHaveLength(0)
expect(b.slots.entries('settings.general.item')).toHaveLength(0)
expect(b.runtime.ctx.get('conversation')).toBeUndefined()
await b.runtime.dispose()
})

View File

@@ -102,18 +102,28 @@ describe('MessageItem arms', () => {
fireEvent.click(screen.getByRole('button', { name: '复制' }))
})
it('steering bubbles render text and non-text rest blocks, without user actions or a badge', () => {
it('consumed steering renders copy and branch actions without a badge', () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
const fork = vi.fn()
const view = render(
<MessageItem t={t} node={{
kind: 'steering', seq: 2, turn: 1, source: null,
kind: 'steering', messageId: 'steer-message', seq: 2, time: 1_000, turn: 1, source: null,
content: [{ type: 'text', text: 'steer!' }, { type: 'image', data: 'x' }] as never,
} as never}
onFork={fork}
/>,
)
expect(view.queryByText('插话')).toBeNull()
expect(view.getByText('steer!')).toBeTruthy()
expect(view.getByText(/附加内容块/)).toBeTruthy()
expect(view.queryByRole('button', { name: '复制' })).toBeNull()
fireEvent.click(view.getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith('steer!')
fireEvent.click(view.getByRole('button', { name: '在新对话中分支' }))
expect(fork).toHaveBeenCalledWith(2)
})
it('context uses the Tool calls disclosure chrome and keeps its JSON collapsed by default', () => {

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

@@ -320,6 +320,42 @@ describe('ToolRow', () => {
})
describe('ThinkRow', () => {
it('follows the latest streaming line, scrolls to its end, then restores the settled first line', () => {
const view = render(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens' }]}
streaming
/>,
)
const summary = view.getByText('Newest reasoning tokens')
Object.defineProperties(summary, {
scrollWidth: { configurable: true, value: 300 },
clientWidth: { configurable: true, value: 100 },
})
view.rerender(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens keep arriving' }]}
streaming
/>,
)
expect(summary.scrollLeft).toBe(200)
expect(summary.getAttribute('data-follow-end')).toBe('true')
view.rerender(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens keep arriving\n' }]}
streaming={false}
/>,
)
expect(view.getByText('Inspect the session')).toBeTruthy()
expect(summary.scrollLeft).toBe(0)
expect(summary.hasAttribute('data-follow-end')).toBe(false)
})
it('expands from either Think or the reasoning summary', () => {
const view = render(
<AssistantMarkdown

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)
}
@@ -251,6 +251,87 @@ describe('ChatView', () => {
expect(view.getByText('run a')).toBeTruthy()
})
it('renders Host-pending steering at the flow tail and hands off to the durable node', () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
const pending = {
id: 'steer-occurrence' as never,
messageId: 'steer-message' as never,
placement: 'steering' as const,
content: [{ type: 'text' as const, text: 'interrupt now' }],
preview: 'interrupt now',
text: 'interrupt now',
}
const queued = {
id: 'queued-occurrence' as never,
messageId: 'queued-message' as never,
placement: 'queued' as const,
content: [{ type: 'text' as const, text: 'later' }],
preview: 'later',
text: 'later',
}
const h = makeHarness({ nodes: [assistant(1, 'working')], queue: [queued, pending], running: true })
const view = render(<h.ChatView {...h.props} />)
expect(view.getByText('interrupt now').closest('[data-pending-steering]')).not.toBeNull()
expect(view.queryByText('later')).toBeNull()
const pendingBubble = view.getByText('interrupt now').closest('[data-pending-steering]')
expect(pendingBubble).not.toBeNull()
fireEvent.click(within(pendingBubble as HTMLElement).getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith('interrupt now')
expect(within(pendingBubble as HTMLElement).queryByRole('button', { name: '在新对话中分支' })).toBeNull()
expect(view.getByRole('status').compareDocumentPosition(view.getByText('interrupt now'))
& Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
act(() => {
h.set({
queue: [queued],
nodes: [
assistant(1, 'working'),
{
kind: 'steering', messageId: pending.messageId,
seq: 2, time: 2_000, turn: 1,
content: [{ type: 'text', text: 'interrupt now' }], source: null,
},
],
})
})
expect(view.getAllByText('interrupt now')).toHaveLength(1)
expect(view.container.querySelector('[data-pending-steering]')).toBeNull()
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(2)
const branchButtons = view.getAllByRole('button', { name: '在新对话中分支' })
expect(branchButtons).toHaveLength(2)
fireEvent.click(branchButtons[1]!)
expect(h.forkAt).toHaveBeenCalledWith(2)
})
it('keeps a later pending occurrence visible when it reuses a durable MessageId', () => {
const pending = {
id: 'steer-occurrence-later' as never,
messageId: 'shared-steer-message' as never,
placement: 'steering' as const,
content: [{ type: 'text' as const, text: 'same steering' }],
preview: 'same steering',
text: 'same steering',
}
const h = makeHarness({
queue: [pending],
nodes: [{
kind: 'steering', messageId: pending.messageId,
seq: 2, time: 2_000, turn: 1,
content: pending.content, source: null,
}],
running: true,
})
const view = render(<h.ChatView {...h.props} />)
expect(view.getAllByText('same steering')).toHaveLength(2)
expect(view.container.querySelectorAll('[data-pending-steering]')).toHaveLength(1)
})
it('animates only the latest unresolved model retry', () => {
const retryNode = retry(2)
const nextRetry = { ...retry(3), turn: 2, retry: 2 }

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

@@ -0,0 +1,67 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore, type SessionListState, type WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { EnterBehaviorRow } from '../src/client/settings/EnterBehaviorRow.tsx'
import type { EnterBehaviorRowProps } from '../src/client/settings/EnterBehaviorRow.tsx'
import { ComposerSubmissionPolicy } from '../src/client/input/submission-policy.ts'
import { en } from '../src/client/locales.ts'
afterEach(() => {
cleanup()
localStorage.clear()
})
function emptySessions() {
return bindSnapshotSelector(createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined,
}))
}
function emptyWorkspaces() {
return bindSnapshotSelector(createSnapshotStore<WorkspaceListState>({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
}))
}
function mount() {
const policy = new ComposerSubmissionPolicy()
const setBusyEnter = vi.fn((behavior: 'queue' | 'steer') => { policy.setBusyEnter(behavior) })
const props: EnterBehaviorRowProps = {
useSessions: emptySessions(),
useWorkspaces: emptyWorkspaces(),
useBusyEnter: bindSnapshotSelector(policy.busyEnter),
setBusyEnter,
t: makeTranslate(en),
}
render(<EnterBehaviorRow {...props} />)
return { policy, setBusyEnter }
}
describe('EnterBehaviorRow', () => {
it('explains the busy-only scope and shows Queue by default', () => {
mount()
expect(screen.getByText('Enter behavior while busy')).toBeDefined()
expect(screen.getByText('Busy only; Cmd/Ctrl+Enter uses the other behavior')).toBeDefined()
expect(screen.getByRole('button', { name: /Queue/ }).getAttribute('aria-expanded')).toBe('false')
})
it('selects Steer, follows later preference changes, and closes outside', () => {
const b = mount()
const trigger = screen.getByRole('button', { name: /Queue/ })
fireEvent.click(trigger)
fireEvent.click(screen.getByRole('menuitem', { name: 'Steer' }))
expect(b.setBusyEnter).toHaveBeenCalledWith('steer')
expect(screen.getByRole('button', { name: /Steer/ })).toBeDefined()
act(() => { b.policy.setBusyEnter('queue') })
const queueTrigger = screen.getByRole('button', { name: /Queue/ })
fireEvent.click(queueTrigger)
expect(screen.getByRole('menuitem', { name: 'Steer' })).toBeDefined()
fireEvent.pointerDown(document.body)
expect(screen.queryByRole('menuitem', { name: 'Steer' })).toBeNull()
})
})

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

@@ -1,6 +1,6 @@
// @vitest-environment jsdom
// InputBar behavior over the machine wiring: Enter-send semantics (IME guard,
// shift newline, ctrl/meta insert, repeat suppression), queue-cut-1 running
// Shift newline, busy Enter policy, Ctrl/Meta steering, repeat suppression), running
// semantics (input stays free; primary turns stop), the machine pending lock,
// decoration backdrop, error/notice strips, and the focus-keeping mousedown.
@@ -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'
@@ -52,6 +53,7 @@ interface BenchOptions {
leftItems?: React.ReactNode
rightItems?: React.ReactNode
commandMenuOpen?: boolean
busyEnter?: 'queue' | 'steer'
toggleCommandMenu?: (selection: { start: number; end: number }) => void
}
@@ -76,6 +78,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 +97,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,
@@ -104,6 +108,11 @@ function bench(over?: BenchOptions) {
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
resolveSubmitMode: (running, gesture, steeringAvailable) => {
if (!running || !steeringAvailable) return 'queue'
const preferred = over?.busyEnter ?? 'queue'
return gesture === 'enter' ? preferred : preferred === 'queue' ? 'steer' : 'queue'
},
toggleCommandMenu: over?.toggleCommandMenu ?? vi.fn(),
useNotices: bindSnapshotSelector(shell.notices),
useLexicon: bindSnapshotSelector(shell.lexicon),
@@ -123,8 +132,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 }
}
@@ -133,7 +143,7 @@ describe('Enter semantics', () => {
it('plain Enter submits queue mode through the machine; repeat and empty are suppressed', () => {
const { textarea, sink } = bench({ draft: 'hello' })
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('hello')
expect(sink).toHaveBeenCalledWith('hello', 'queue')
fireEvent.keyDown(textarea, { key: 'Enter', repeat: true })
expect(sink).toHaveBeenCalledTimes(1)
const empty = bench({ draft: ' ' })
@@ -155,12 +165,18 @@ describe('Enter semantics', () => {
expect(sink).not.toHaveBeenCalled() // and not preventDefault'd: native newline
})
it('Ctrl/Meta+Enter inserts a newline through the machine (no browser execCommand)', () => {
const { textarea, shell, sink } = bench({ draft: 'hello' })
textarea.setSelectionRange(5, 5)
fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true })
expect(shell.snapshot.draft).toBe('hello\n')
expect(sink).not.toHaveBeenCalled()
it('Ctrl/Meta+Enter sends normally while idle and steers while running', () => {
const idle = bench({ draft: 'hello' })
fireEvent.keyDown(idle.textarea, { key: 'Enter', metaKey: true })
expect(idle.sink).toHaveBeenCalledWith('hello', 'queue')
const busyCtrl = bench({ running: true, draft: 'steer with ctrl' })
fireEvent.keyDown(busyCtrl.textarea, { key: 'Enter', ctrlKey: true })
expect(busyCtrl.sink).toHaveBeenCalledWith('steer with ctrl', 'steer')
const busyMeta = bench({ running: true, draft: 'steer with cmd' })
fireEvent.keyDown(busyMeta.textarea, { key: 'Enter', metaKey: true })
expect(busyMeta.sink).toHaveBeenCalledWith('steer with cmd', 'steer')
})
it('platform undo/redo chords route to the machine, never the browser stack', () => {
@@ -201,12 +217,78 @@ describe('running and lock semantics (queue cut 1)', () => {
expect(textarea.disabled).toBe(false) // running no longer locks
fireEvent.change(textarea, { target: { value: '排队消息2' } })
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('排队消息2')
expect(sink).toHaveBeenCalledWith('排队消息2', 'queue')
expect(button.getAttribute('aria-label')).toBe('停止生成')
fireEvent.click(button)
expect(stop).toHaveBeenCalledTimes(1)
})
it('running plain Enter follows the busy-state Steer preference', () => {
const { textarea, sink } = bench({ running: true, busyEnter: 'steer', draft: '直接插话' })
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('直接插话', 'steer')
})
it('running Cmd/Ctrl+Enter uses the opposite of the busy-state Enter preference', () => {
const meta = bench({ running: true, busyEnter: 'steer', draft: '排到下一轮' })
fireEvent.keyDown(meta.textarea, { key: 'Enter', metaKey: true })
expect(meta.sink).toHaveBeenCalledWith('排到下一轮', 'queue')
const ctrl = bench({ running: true, busyEnter: 'steer', draft: 'also queue' })
fireEvent.keyDown(ctrl.textarea, { key: 'Enter', ctrlKey: true })
expect(ctrl.sink).toHaveBeenCalledWith('also queue', 'queue')
})
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('后续消息', 'queue')
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('keeps both running subagent Enter gestures on Queue transport', () => {
const subagent = {
address: {
parentSessionId: 'parent' as SessionId,
childSessionId: SID,
mode: 'continuable' as const,
},
parentAvailable: true,
}
const plain = bench({ running: true, busyEnter: 'steer', draft: 'plain', subagent })
fireEvent.keyDown(plain.textarea, { key: 'Enter' })
expect(plain.sink).toHaveBeenCalledWith('plain', 'queue')
const accelerated = bench({ running: true, draft: 'accelerated', subagent })
fireEvent.keyDown(accelerated.textarea, { key: 'Enter', metaKey: true })
expect(accelerated.sink).toHaveBeenCalledWith('accelerated', 'queue')
})
it('disabled (session removed) locks the textarea and chrome', () => {
const { textarea, view } = bench({ disabled: true })
expect(textarea.disabled).toBe(true)
@@ -217,7 +299,7 @@ describe('running and lock semantics (queue cut 1)', () => {
it('idle primary sends and disables on empty draft', () => {
const { button, sink } = bench({ draft: 'go' })
fireEvent.click(button)
expect(sink).toHaveBeenCalledWith('go')
expect(sink).toHaveBeenCalledWith('go', 'queue')
const empty = bench()
expect(empty.button.disabled).toBe(true)
})

View File

@@ -40,9 +40,9 @@ function effectAt<T extends InputEffect['type']>(
}
/** Drive plain → adjudicating and hand back the minted attempt. */
function enterAdjudicating(m: InputMachine, draft: string): SubmitAttempt {
function enterAdjudicating(m: InputMachine, draft: string, mode: 'queue' | 'steer' = 'queue'): SubmitAttempt {
m.dispatch({ type: 'draft-changed', draft })
const fx = m.dispatch({ type: 'enter' })
const fx = m.dispatch({ type: 'enter', mode })
return effectAt(fx, 0, 'adjudicate').attempt
}
@@ -52,35 +52,42 @@ function enterSubmitting(m: InputMachine, name: string, args: string): { attempt
m.dispatch({ type: 'draft-changed', draft: `/${name.slice(0, 2)}` })
m.dispatch({ type: 'begin-command', claim, span: spanOf(m, 0, m.state.draft.length) })
m.dispatch({ type: 'draft-changed', draft: claim.token + args })
const fx = m.dispatch({ type: 'enter' })
const fx = m.dispatch({ type: 'enter', mode: 'queue' })
return { attempt: effectAt(fx, 0, 'begin-submit').attempt, claim }
}
function staleAttempt(): SubmitAttempt {
return { seq: 9999, signal: new AbortController().signal, draftSnapshot: '' }
return { seq: 9999, signal: new AbortController().signal, draftSnapshot: '', mode: 'queue' }
}
describe('input-machine: plain × enter', () => {
it('empty and whitespace-only drafts produce nothing', () => {
const m = new InputMachine()
expect(m.dispatch({ type: 'enter' })).toEqual([])
expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
m.dispatch({ type: 'draft-changed', draft: ' \n ' })
expect(m.dispatch({ type: 'enter' })).toEqual([])
expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
expect(m.state.phase).toBe('plain')
})
it('non-command text falls to the default sink', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: 'hello world' })
expect(m.dispatch({ type: 'enter' }))
.toEqual([{ type: 'default-sink', draft: 'hello world' }])
expect(m.dispatch({ type: 'enter', mode: 'queue' }))
.toEqual([{ type: 'default-sink', draft: 'hello world', mode: 'queue' }])
expect(m.state.phase).toBe('plain')
})
it('retains an explicit steer mode on the default sink effect', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: 'steer now' })
expect(m.dispatch({ type: 'enter', mode: 'steer' }))
.toEqual([{ type: 'default-sink', draft: 'steer now', mode: 'steer' }])
})
it('leading "/" enters adjudicating with a minted attempt carrying the draft snapshot', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '/goal x' })
const fx = m.dispatch({ type: 'enter' })
const fx = m.dispatch({ type: 'enter', mode: 'queue' })
const eff = effectAt(fx, 0, 'adjudicate')
expect(eff.draft).toBe('/goal x')
expect(eff.attempt.draftSnapshot).toBe('/goal x')
@@ -91,14 +98,14 @@ describe('input-machine: plain × enter', () => {
it('leading is judged after trim including newlines', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '\n\n/goal x' })
expect(m.dispatch({ type: 'enter' })[0]?.type).toBe('adjudicate')
expect(m.dispatch({ type: 'enter', mode: 'queue' })[0]?.type).toBe('adjudicate')
})
it('a non-whitespace prefix before "/" is not leading — default sink', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '第一行\n/goal x' })
expect(m.dispatch({ type: 'enter' }))
.toEqual([{ type: 'default-sink', draft: '第一行\n/goal x' }])
expect(m.dispatch({ type: 'enter', mode: 'queue' }))
.toEqual([{ type: 'default-sink', draft: '第一行\n/goal x', mode: 'queue' }])
})
})
@@ -126,9 +133,9 @@ describe('input-machine: adjudication outcomes', () => {
it('undefined outcome falls back to the default sink', () => {
const m = new InputMachine()
const attempt = enterAdjudicating(m, '/unknown thing')
const attempt = enterAdjudicating(m, '/unknown thing', 'steer')
expect(m.dispatch({ type: 'adjudicated', attempt, outcome: undefined }))
.toEqual([{ type: 'default-sink', draft: '/unknown thing' }])
.toEqual([{ type: 'default-sink', draft: '/unknown thing', mode: 'steer' }])
expect(m.state.phase).toBe('plain')
})
@@ -152,7 +159,7 @@ describe('input-machine: adjudication outcomes', () => {
it('enter is a no-op while adjudicating (pending lock)', () => {
const m = new InputMachine()
enterAdjudicating(m, '/goal x')
expect(m.dispatch({ type: 'enter' })).toEqual([])
expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
expect(m.state.phase).toBe('adjudicating')
})
@@ -344,31 +351,6 @@ describe('input-machine: occurrence reconciliation on draft edits', () => {
})
})
describe('input-machine: newline transaction (F1)', () => {
it('inserts \\n at the caret and shifts trailing occurrences', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: 'ab @wor' })
m.dispatch({ type: 'insert-ref', reference: refOf('w'), span: spanOf(m, 3, 7) })
m.dispatch({ type: 'newline', selection: { start: 2, end: 2 } })
expect(m.state.draft).toBe(`ab\n ${P} `)
expect(m.state.occurrences[0]?.offset).toBe(4)
m.dispatch({ type: 'undo' })
expect(m.state.draft).toBe(`ab ${P} `)
})
it('replaces a selection, breaks the claim prefix when leading, and rejects out-of-bounds', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '/go' })
m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
expect(m.dispatch({ type: 'newline', selection: { start: 0, end: 99 } })).toEqual([])
expect(m.state.phase).toBe('claimed')
m.dispatch({ type: 'newline', selection: { start: 0, end: 0 } })
expect(m.state.draft).toBe('\n/goal ')
expect(m.state.phase).toBe('plain')
expect(m.state.claim).toBeUndefined()
})
})
describe('input-machine: consume-token guards', () => {
it('span guard: CAS pass deletes the token — success observable as a draftRev advance', () => {
const m = new InputMachine()
@@ -587,7 +569,7 @@ describe('input-machine: paste plane', () => {
const b = new InputMachine()
b.dispatch({ type: 'paste-begin', text: 'plain text', selection: { start: 0, end: 0 } })
b.dispatch({ type: 'enter' })
b.dispatch({ type: 'enter', mode: 'queue' })
expect(b.state.paste).toBeUndefined()
})
@@ -755,7 +737,7 @@ describe('input-machine: submitting transaction', () => {
it('enter and begin-command are locked; draft-changed is recorded without leaving submitting', () => {
const m = new InputMachine()
enterSubmitting(m, 'goal', 'x')
expect(m.dispatch({ type: 'enter' })).toEqual([])
expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
expect(m.dispatch({ type: 'draft-changed', draft: '/goal y' })).toEqual([])
expect(m.state).toMatchObject({ phase: 'submitting', draft: '/goal y' })
})
@@ -768,7 +750,7 @@ describe('input-machine: submitting transaction', () => {
m.dispatch({ type: 'draft-changed', draft: '/go', editRange: { start: 0, end: 1, insertedLength: 0 } })
m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
m.dispatch({ type: 'draft-changed', draft: '/goal go' })
const attempt = effectAt(m.dispatch({ type: 'enter' }), 0, 'begin-submit').attempt
const attempt = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'begin-submit').attempt
const fx = m.dispatch({ type: 'submit-settled', attempt, ok: true, outcome: { kind: 'success', text: 'goal set' } })
expect(fx).toEqual([{ type: 'notice', level: 'info', text: 'goal set' }])
expect(m.state).toMatchObject({ phase: 'plain', draft: '', occurrences: [] })
@@ -809,7 +791,7 @@ describe('input-machine: submitting transaction', () => {
const m = new InputMachine()
const { attempt: first } = enterSubmitting(m, 'goal', 'x')
m.dispatch({ type: 'submit-settled', attempt: first, ok: false, message: 'retry' })
const second = effectAt(m.dispatch({ type: 'enter' }), 0, 'begin-submit').attempt
const second = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'begin-submit').attempt
expect(second.seq).not.toBe(first.seq)
expect(m.dispatch({ type: 'submit-settled', attempt: first, ok: true })).toEqual([])
expect(m.state.phase).toBe('submitting')

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,
@@ -46,6 +47,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
resolveSubmitMode: () => 'queue',
toggleCommandMenu: vi.fn(),
useNotices: bindSnapshotSelector(shell.notices),
useLexicon: bindSnapshotSelector(shell.lexicon),
@@ -87,7 +89,7 @@ describe('matrix row: plain', () => {
fireEvent.change(textarea, { target: { value: '普通消息' } })
expect(shell.snapshot.claim).toBeUndefined()
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('普通消息')
expect(sink).toHaveBeenCalledWith('普通消息', 'queue')
expect(shell.snapshot.phase).toBe('plain')
})
})
@@ -186,7 +188,7 @@ describe('matrix row: locked (session disabled)', () => {
expect((textarea).disabled).toBe(false)
fireEvent.change(textarea, { target: { value: '排队' } })
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('排队')
expect(sink).toHaveBeenCalledWith('排队', 'queue')
})
})

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,
@@ -132,6 +133,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
resolveSubmitMode: () => 'queue',
toggleCommandMenu: (selection) => {
const snapshot = shell.snapshot
controller.toggleSource('command', {
@@ -234,7 +236,7 @@ describe('scenario D: execute-kind /compact', () => {
act(() => { b2.shell.setDraft('/compact 现在') })
fireEvent.keyDown(b2.textarea, { key: 'Enter' })
// execute with trailing → matchEnter answers undefined → default sink.
await vi.waitFor(() => { expect(b2.sink).toHaveBeenCalledWith('/compact 现在') })
await vi.waitFor(() => { expect(b2.sink).toHaveBeenCalledWith('/compact 现在', 'queue') })
expect(b2.executed).toHaveLength(0)
})
})
@@ -288,7 +290,7 @@ describe('scenario I: unknown /xyz + enter', () => {
const b = await bench()
act(() => { b.shell.setDraft('/xyz 干点啥') })
fireEvent.keyDown(b.textarea, { key: 'Enter' })
await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥') })
await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥', 'queue') })
expect(b.shell.snapshot.phase).toBe('plain')
expect(b.execute).not.toHaveBeenCalled()
})

View File

@@ -1,7 +1,7 @@
// @vitest-environment jsdom
/**
* QueueDock rendering and operations: authoritative rows, inline editing,
* collapse state, removal, failure notices, and live retirement.
* collapse state, removal, strict steering, failure notices, and live retirement.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
@@ -23,14 +23,18 @@ const SID = 's1' as SessionId
const iid = (id: string): QueueItemId => id as QueueItemId
function row(id: string, text: string | null, preview = text ?? '[image]'): QueuedMessage {
return { id: iid(id), preview, text }
return {
id: iid(id), messageId: `message-${id}` as never, placement: 'queued',
content: text === null ? [{ type: 'image', data: 'x' } as never] : [{ type: 'text', text }],
preview, text,
}
}
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,
}
}
@@ -85,6 +89,14 @@ describe('QueueDock', () => {
expect(container.innerHTML).toBe('')
})
it('leaves pending steering to the conversation flow', () => {
const steering = { ...row('s-1', 'interrupt'), placement: 'steering' as const }
const snap = snapshotWith([steering])
const source = liveSession(snap)
const { container } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
expect(container.innerHTML).toBe('')
})
it('renders one row directly and defaults multiple rows to a collapsible count header', () => {
const single = snapshotWith([row('i-1', 'one')])
const source = liveSession(single)
@@ -187,10 +199,10 @@ describe('QueueDock', () => {
fireEvent.click(getByRole('button', { name: '2 条排队消息' }))
expect([...container.querySelectorAll('li')].map(item => item.textContent))
.toEqual(['第一条排队消息', 'image [image]'])
expect(container.querySelectorAll('button')).toHaveLength(5)
expect(container.querySelectorAll('button')).toHaveLength(7)
expect(container.querySelectorAll('[aria-label="编辑排队消息"]')).toHaveLength(2)
expect(container.querySelectorAll('[aria-label="删除排队消息"]')).toHaveLength(2)
expect(container.querySelectorAll('[aria-label="立即发送排队消息"]')).toHaveLength(0)
expect(container.querySelectorAll('[aria-label="插话发送"]')).toHaveLength(2)
expect((container.querySelectorAll('[aria-label="编辑排队消息"]')[0] as HTMLButtonElement).disabled).toBe(false)
expect((container.querySelectorAll('[aria-label="编辑排队消息"]')[1] as HTMLButtonElement).disabled).toBe(true)
expect(container.querySelectorAll('[aria-label="编辑排队消息"]')[1]?.getAttribute('title'))
@@ -273,6 +285,68 @@ describe('QueueDock', () => {
})
})
it('strictly steers complete row content only while the agent is running', async () => {
const running = snapshotWith([row('i-steer', null, 'image [image]')])
const source = liveSession(running)
const updateQueue = vi.fn(() => Promise.resolve())
const rendered = render(
<QueueDock {...kitFor(running, { updateQueue })} useSession={source.useSession} />,
)
const button = rendered.getByLabelText('插话发送')
expect(button).toHaveProperty('disabled', false)
fireEvent.click(button)
await waitFor(() => {
expect(updateQueue).toHaveBeenCalledWith(iid('i-steer'), { kind: 'steer' })
})
act(() => { source.push({ ...running, running: false }) })
expect(rendered.getByLabelText('插话发送')).toHaveProperty('disabled', true)
expect(rendered.getByLabelText('插话发送').getAttribute('title')).toBe('仅运行中可插话发送')
})
it('renders a session-backed subagent Queue without unsupported actions', () => {
const snap = {
...snapshotWith([row('i-subagent', 'pending child follow-up')]),
subagent: {
address: {
parentSessionId: 'parent' as SessionId,
childSessionId: SID,
mode: 'continuable' as const,
},
parentAvailable: true,
},
}
const source = liveSession(snap)
const view = render(
<QueueDock {...kitFor(snap)} useSession={source.useSession} />,
)
expect(view.getByText('pending child follow-up')).toBeTruthy()
expect(view.queryByLabelText('编辑排队消息')).toBeNull()
expect(view.queryByLabelText('删除排队消息')).toBeNull()
expect(view.queryByLabelText('插话发送')).toBeNull()
})
it('keeps the row and reports a genuine steer failure', async () => {
const snap = snapshotWith([row('i-steer-race', 'pending steer')])
const source = liveSession(snap)
const notify = vi.fn()
const updateQueue = vi.fn(() => Promise.reject(new Error('transport failed')))
const { getByLabelText, getByText } = render(
<QueueDock {...kitFor(snap, { updateQueue, notify })} useSession={source.useSession} />,
)
fireEvent.click(getByLabelText('插话发送'))
await waitFor(() => {
expect(notify).toHaveBeenCalledWith(
'error',
'插话发送失败,请重试。',
)
})
expect(getByText('pending steer')).toBeTruthy()
})
it('keeps the row and surfaces a notice when an operation loses the claim race', async () => {
const snap = snapshotWith([row('i-race', 'pending')])
const source = liveSession(snap)

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

@@ -50,6 +50,29 @@ describe('ConversationService', () => {
await expect(b.scoped.send('x')).rejects.toThrow('conversation.send failed: agent-busy: busy')
b.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'nope', details: {} } } as never)
await expect(b.scoped.cancel()).rejects.toThrow('conversation.cancel failed: internal: nope')
b.updateQueue.mockResolvedValueOnce({
ok: false, error: { code: 'internal', message: 'broken', details: {} },
} as never)
await expect(b.scoped.updateQueue('item-1' as never, { kind: 'steer' }))
.rejects.toThrow('conversation.updateQueue failed: internal: broken')
await b.runtime.dispose()
})
it('treats strict-steer races as converged Queue delivery', async () => {
const b = await bench()
b.updateQueue.mockResolvedValueOnce({
ok: false, error: { code: 'steer-unavailable', message: 'closed', details: {} },
} as never)
await expect(b.scoped.updateQueue('item-1' as never, { kind: 'steer' })).resolves.toBeUndefined()
b.updateQueue.mockResolvedValueOnce({
ok: false, error: { code: 'queue-item-not-found', message: 'claimed', details: {} },
} as never)
await expect(b.scoped.updateQueue('item-2' as never, { kind: 'steer' })).resolves.toBeUndefined()
b.updateQueue.mockResolvedValueOnce({
ok: false, error: { code: 'queue-item-not-found', message: 'claimed', details: {} },
} as never)
await expect(b.scoped.updateQueue('item-3' as never, { kind: 'remove' }))
.rejects.toThrow('conversation.updateQueue failed: queue-item-not-found: claimed')
await b.runtime.dispose()
})

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}
/>
)
@@ -158,6 +164,7 @@ function mount(
useInput={useInput}
inputActions={inputActions}
keyboard={wiring}
resolveSubmitMode={() => 'queue'}
toggleCommandMenu={vi.fn()}
useNotices={bindSnapshotSelector(wiring.notices)}
useLexicon={bindSnapshotSelector(wiring.lexicon)}
@@ -200,7 +207,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} />) },
}
@@ -214,11 +221,19 @@ describe('ConversationRoot resident composer', () => {
fireEvent.change(box, { target: { value: 'ordinary revised' } })
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.sink).toHaveBeenCalledWith('ordinary revised', 'queue')
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

@@ -0,0 +1,67 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
BUSY_ENTER_STORAGE_KEY, ComposerSubmissionPolicy, DEFAULT_BUSY_ENTER_BEHAVIOR,
} from '../src/client/input/submission-policy.ts'
afterEach(() => {
vi.unstubAllGlobals()
localStorage.clear()
})
describe('ComposerSubmissionPolicy', () => {
it('defaults to Queue and only applies the preference while running', () => {
const policy = new ComposerSubmissionPolicy()
expect(policy.busyEnter.getSnapshot()).toBe(DEFAULT_BUSY_ENTER_BEHAVIOR)
expect(policy.resolve(false, 'enter', true)).toBe('queue')
expect(policy.resolve(false, 'accelerated', true)).toBe('queue')
expect(policy.resolve(true, 'enter', true)).toBe('queue')
expect(policy.resolve(true, 'accelerated', true)).toBe('steer')
expect(policy.resolve(true, 'enter', false)).toBe('queue')
expect(policy.resolve(true, 'accelerated', false)).toBe('queue')
const changed = vi.fn()
policy.busyEnter.subscribe(changed)
policy.setBusyEnter('steer')
expect(changed).toHaveBeenCalledTimes(1)
expect(policy.resolve(true, 'enter', true)).toBe('steer')
expect(policy.resolve(true, 'accelerated', true)).toBe('queue')
expect(policy.resolve(false, 'enter', true)).toBe('queue')
expect(policy.resolve(false, 'accelerated', true)).toBe('queue')
expect(localStorage.getItem(BUSY_ENTER_STORAGE_KEY)).toBe('steer')
})
it('restores a valid preference and leaves an identical write untouched', () => {
localStorage.setItem(BUSY_ENTER_STORAGE_KEY, 'steer')
const write = vi.spyOn(Storage.prototype, 'setItem')
const policy = new ComposerSubmissionPolicy()
expect(policy.busyEnter.getSnapshot()).toBe('steer')
policy.setBusyEnter('steer')
expect(write).not.toHaveBeenCalled()
write.mockRestore()
})
it('uses Queue for invalid, unavailable, or unreadable storage', () => {
localStorage.setItem(BUSY_ENTER_STORAGE_KEY, 'invalid')
expect(new ComposerSubmissionPolicy().busyEnter.getSnapshot()).toBe('queue')
vi.stubGlobal('localStorage', undefined)
expect(new ComposerSubmissionPolicy().busyEnter.getSnapshot()).toBe('queue')
vi.stubGlobal('localStorage', {
getItem: () => { throw new Error('blocked') },
setItem: vi.fn(),
})
expect(new ComposerSubmissionPolicy().busyEnter.getSnapshot()).toBe('queue')
})
it('keeps the in-memory preference when persistence throws', () => {
vi.stubGlobal('localStorage', {
getItem: () => null,
setItem: () => { throw new Error('quota') },
})
const policy = new ComposerSubmissionPolicy()
policy.setBusyEnter('steer')
expect(policy.busyEnter.getSnapshot()).toBe('steer')
})
})

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,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>,

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