feat(web): add plan mode controls

This commit is contained in:
fz
2026-07-24 12:55:23 +08:00
parent bc63b5fe00
commit 9a69feeec0
41 changed files with 1047 additions and 27 deletions
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-24-web-plan-mode.md: 42407198c09c34f2ddde559f5ddeae8d7736169e
2026-07-24-web-plan-mode.zh.md: db865a26e91977670ed520aecf9d683a6bea5dab
@@ -0,0 +1,59 @@
# Agent Note: Compose plan mode into the Web product
Status: implemented
English | [中文](2026-07-24-web-plan-mode.zh.md)
## Problem
The Web host can project plan state through the [optional session RPC contract](../architecture/2026-07-24-web-plan-mode-projection.md), but the shipped Web product did not mount the plan service or expose a mode control. Mounting only a browser toggle would leave the model without plan guidance and `exit_plan_mode`; mounting only the host service would make the feature undiscoverable and hide state waiting for the next request boundary.
Plan selection is not a stop command. The plan service deliberately queues the latest target for a model-request boundary so the current request remains internally consistent. The UI must show both the committed state and that pending target, including `pending: false`, without speculating past the host response. The model-driven exit also needs the existing structured plan review rather than a second Web-only approval path.
## Decision
`@deepseek-ai/dsh-client-ui-plan` is one Web feature plugin with lifecycle-coupled host and browser entries. Its host entry mounts `@deepseek-ai/dsh-plan-mode` with the Web product's complete planning policy. Its browser entry registers `PlanModeControl` into the session-scoped `conversation.composer.controls` list slot. The `dsh web` roster selects the package once; plugin discovery loads the browser entry while the same roster mount supplies the host behavior.
The policy is product-owned configuration at this composition boundary. It tells the model to inspect before planning, avoid mutation while planning, resolve discoverable facts without asking, make the plan decision-complete, and submit it as the only final `exit_plan_mode` call. The plan package continues to own logged state, boundary timing, prompt-section activation, the stable exit-tool schema, and review semantics. The Web plugin does not copy any of those mechanisms.
`ui-conversation` owns and renders the new additive controls slot to the left of the primary composer action. It provides no business payload; entries receive the standard session kit. Whole-composer replacements remain on the separate selector-routed `conversation.composer` chain, so a pending question replaces the InputBar and its controls without either feature importing the other.
The control is absent when `planMode` is `null`. Otherwise, its selected value is `pending ?? active`, while pending presentation tests field presence rather than truthiness. It displays `计划 · 待生效` or `默认 · 待生效` until a logged commit replaces the snapshot. A selection disables only the selector while its own RPC is in flight. Generation does not disable it: selecting during a running turn neither calls cancel nor changes that turn, and cancelling generation does not clear the pending target.
## Interaction semantics
| Observed state | Control | User action | Result |
|---|---|---|---|
| `{ active: false }` | Default | Select Plan | Host confirms `{ active: false, pending: true }` |
| `{ active: false, pending: true }` | Plan, pending | Send a prompt | Boundary logs `plan/mode: true`; control becomes committed Plan |
| `{ active: true }`, running | Plan | Select Default | Turn continues; control shows Default pending |
| `{ active: true, pending: false }` | Default, pending | Stop | Pending target survives; the next prompt commits Default |
| `null` | Hidden | — | Composition exposes no unsupported control |
Business and transport failures leave the confirmed snapshot unchanged, re-enable the selector, and render a compact visible failure beside it. The component guards asynchronous completion after unmount so switching sessions cannot update a retired control.
## Exit review
`exit_plan_mode` remains registered in both modes for request-cache stability. In plan mode the model submits the complete Markdown plan through that tool. The plan service asks through `ctx.userInteraction`, and the already-composed Web question plugin presents the plan detail with Approve, Keep planning, and the free-text answer channel.
Approval queues inactive mode for the next step; it does not rewrite the current tool batch. Keep planning or custom feedback leaves plan mode active and returns corrective feedback to the model. If the review channel is unavailable or aborted, the tool fails closed and the policy tells the model to ask the user to switch modes manually.
## Product composition and evidence
Fixture mode implements the same pending and boundary behavior in memory so browser acceptance tests exercise the assembled product without a key. The keyless browser flow selects Plan, commits it with a prompt, selects Default during generation, stops without losing the pending target, and commits Default with the next prompt. A file snapshot records each user-visible state. A real `dsh web` process with a mock provider additionally proves that the roster mounts plan mode, the state RPC reports capability, and the active Web policy reaches the provider request alongside workspace instructions.
## Alternatives considered
**Put the selector directly in `ui-conversation`.** Rejected because the conversation skeleton would acquire plan-domain knowledge and a host dependency. The additive slot keeps feature ownership in `ui-plan` and leaves room for independent controls.
**Mount plan mode unconditionally in the host runtime.** Rejected because plan mode is a product composition choice. The optional RPC contract must continue to represent hosts that do not select it.
**Optimistically flip a local boolean.** Rejected because a host append failure, another surface, resume, or tool-reviewed exit can disagree. The control displays only host-confirmed committed and pending state.
**Disable switching while generation runs or make switching stop the turn.** Rejected because it changes the plan service's boundary contract and couples collaboration state to cancellation. The pending state exists specifically to keep those operations independent.
**Create a Web-specific plan approval component.** Rejected because plan review is already a structured user-interaction question. Reusing the question composer preserves one review protocol and the free-text correction path.
## Consequences
The Web product now exposes the same plan interaction model as the current terminal and ACP compositions while retaining its plugin boundaries. Selecting the feature adds one host policy/tool owner and one browser slot entry; removing its fiber removes both. The model tool catalog stays stable across mode changes, but the active system-prompt section changes at a plan boundary and therefore changes the request prefix. Plan mode remains guidance, not an execution sandbox: deployments that require enforced read-only planning still compose the independent sandbox and approval policies.
@@ -0,0 +1,59 @@
# Agent Note: 将 plan mode 组合进 Web 产品
Status: implemented
[English](2026-07-24-web-plan-mode.md) | 中文
## 问题
Web 宿主可以通过[可选会话 RPC 契约](../architecture/2026-07-24-web-plan-mode-projection.md)投影 plan 状态,但已交付的 Web 产品既未挂载 plan 服务,也未公开模式控件。若只挂载浏览器切换控件,模型将缺少 plan 引导和 `exit_plan_mode`;若只挂载宿主服务,该功能将难以发现,也无法显示正在等待下一请求边界的状态。
选择 plan mode 并不是停止命令。plan 服务会有意将最新目标排到模型请求边界生效,使当前请求在内部保持一致。UI 必须同时显示已提交状态和待生效目标,包括 `pending: false`,且不能越过宿主响应进行推测。由模型发起的退出也必须沿用现有的结构化 plan 评审,不能再建立一条仅供 Web 使用的审批路径。
## 决策
`@deepseek-ai/dsh-client-ui-plan` 是一个 Web 功能插件,包含生命周期耦合的宿主入口和浏览器入口。宿主入口使用 Web 产品的完整 plan 策略挂载 `@deepseek-ai/dsh-plan-mode`。浏览器入口则把 `PlanModeControl` 注册到会话作用域的 `conversation.composer.controls` 列表槽。`dsh web` 的插件清单只需选择该包一次:插件发现机制会加载浏览器入口,同一次清单挂载则提供宿主行为。
该策略是此组合边界上由产品拥有的配置。它要求模型先检查再规划、规划期间避免修改、无需询问即可自行查明能够发现的事实、使 plan 包含完成决策所需的全部信息,并把 plan 作为唯一且最终的 `exit_plan_mode` 调用提交。plan 包(package)继续拥有已记录的状态、边界时序、提示词段激活、稳定的退出工具 schema 和评审语义。Web 插件不会复制其中任何机制。
`ui-conversation` 拥有并渲染新增的可叠加控件槽,其位置在 composer 主操作左侧。该槽不提供业务载荷;各入口接收标准会话注入项。替换整个 composer 的功能仍走另一条由选择器路由的 `conversation.composer` 链,因此待处理的问题会替换 InputBar 及其控件,两个功能均无需导入对方。
`planMode``null` 时,控件不会出现。否则,其选中值为 `pending ?? active`,而待生效状态的呈现依据是字段是否存在,而非字段真值。在日志提交替换快照前,控件会显示 `计划 · 待生效``默认 · 待生效`。选择操作只会在自身 RPC 执行期间禁用选择器。生成过程不会禁用该控件:在运行中的轮次里选择模式,既不会调用取消,也不会改变该轮次;取消生成也不会清除待生效目标。
## 交互语义
| 观察到的状态 | 控件 | 用户操作 | 结果 |
|---|---|---|---|
| `{ active: false }` | 默认 | 选择「计划」 | 宿主确认 `{ active: false, pending: true }` |
| `{ active: false, pending: true }` | 计划,待生效 | 发送提示词 | 边界记录 `plan/mode: true`;控件变为已提交的「计划」 |
| `{ active: true }`,运行中 | 计划 | 选择「默认」 | 轮次继续;控件显示「默认,待生效」 |
| `{ active: true, pending: false }` | 默认,待生效 | 停止 | 待生效目标保留;下一条提示词提交「默认」 |
| `null` | 隐藏 | — | 产品组合不会公开不受支持的控件 |
业务故障和传输故障都会保持已确认的快照不变,重新启用选择器,并在其旁边渲染一条紧凑且可见的失败信息。组件会防止卸载后完成的异步操作继续更新状态,因此切换会话不会更新已经退出使用的控件。
## 退出评审
`exit_plan_mode` 在两种模式下都会保持注册,以维持请求缓存稳定性。在 plan mode 中,模型通过该工具提交完整的 Markdown plan。plan 服务经由 `ctx.userInteraction` 发起询问,已组合进 Web 的问题插件会展示 plan 详情,并提供「批准」、「继续规划」和自由文本回答渠道。
批准会将未激活模式排到下一步骤生效,不会重写当前工具批次。选择继续规划或提供自定义反馈时,plan mode 保持激活,并向模型返回修正反馈。若评审渠道不可用或已中止,工具会采取失败关闭策略,策略则要求模型请用户手动切换模式。
## 产品组合与证据
fixture(测试前置数据)模式在内存中实现相同的待生效与边界行为,因此浏览器验收测试无需密钥即可覆盖组合后的产品。无密钥浏览器流程依次选择「计划」、通过提示词提交该模式、在生成期间选择「默认」、停止生成且不丢失待生效目标,再通过下一条提示词提交「默认」。文件快照记录每个用户可见状态。另一个使用 mock 提供方的真实 `dsh web` 进程还证明:插件清单会挂载 plan mode,状态 RPC 会报告该功能,并且激活的 Web 策略会随工作区指令一同进入提供方请求。
## 考虑过的替代方案
**把选择器直接放进 `ui-conversation`。** 不予采纳,因为会话骨架将因此获得 plan 领域知识和宿主依赖。新增的可叠加槽让 `ui-plan` 保持对功能的归属,并为彼此独立的控件留出空间。
**在宿主运行时无条件挂载 plan mode。** 不予采纳,因为 plan mode 是产品组合选择。可选 RPC 契约必须继续表达未选择该功能的宿主。
**乐观地翻转本地布尔值。** 不予采纳,因为宿主追加失败、其他界面、恢复或经工具评审的退出都可能与该值不一致。控件只显示宿主确认的已提交状态和待生效状态。
**生成运行期间禁用切换,或让切换停止当前轮次。** 不予采纳,因为这会改变 plan 服务的边界契约,并将协作状态与取消操作耦合。待生效状态的存在,正是为了让这两项操作彼此独立。
**创建 Web 专用的 plan 审批组件。** 不予采纳,因为 plan 评审已经是一项结构化的用户交互问题。复用问题 composer 可以保留单一评审协议和自由文本修正路径。
## 后果
Web 产品现在会在保留插件边界的同时,公开与现有终端和 ACPAgent Client Protocol)产品组合相同的 plan 交互模型。选择该功能会增加一个宿主策略/工具所有者和一个浏览器槽入口;移除其 fiber 会同时移除二者。模型工具目录在模式切换期间保持稳定,但激活的系统提示词段会在 plan 边界发生变化,因此请求前缀也会改变。Plan mode 仍是引导机制,而非执行沙箱:需要强制只读规划的部署仍需组合彼此独立的沙箱策略和审批策略。
+1
View File
@@ -20,6 +20,7 @@
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
"@deepseek-ai/dsh-client-ui-plan": "workspace:^",
"@deepseek-ai/dsh-client-ui-question": "workspace:^",
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
+1
View File
@@ -37,6 +37,7 @@ const CLIENT_PACKAGES = [
'@deepseek-ai/dsh-client-ui-layout',
'@deepseek-ai/dsh-client-ui-sidebar',
'@deepseek-ai/dsh-client-ui-conversation',
'@deepseek-ai/dsh-client-ui-plan',
'@deepseek-ai/dsh-client-ui-question',
'@deepseek-ai/dsh-client-ui-trajectory',
] as const
+3
View File
@@ -53,6 +53,9 @@
{
"path": "../../packages/client/ui-conversation"
},
{
"path": "../../packages/client/ui-plan"
},
{
"path": "../../packages/client/ui-trajectory"
},
+56 -1
View File
@@ -14,6 +14,7 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
{ id: '@deepseek-ai/dsh-client-ui-plan', dir: 'ui-plan', url: '/plugins/ui-plan.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
{ id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
]
@@ -77,7 +78,8 @@ function titleSurfaces(label: string): { sidebar: string; breadcrumb: string; do
return { sidebar, breadcrumb, documentTitle: document.title }
}
it('projects initial and revised durable titles through the built eight-plugin fixture app', async () => {
/** Boot the built fixture graph into the per-test root. */
function bootFixtureApp(): void {
const root = document.querySelector<HTMLElement>('#root')
if (root === null) throw new Error('snapshot root missing')
act(() => {
@@ -89,7 +91,10 @@ it('projects initial and revised durable titles through the built eight-plugin f
executeBundle: (code) => { (0, eval)(code) },
})
})
}
it('projects initial and revised durable titles through the built nine-plugin fixture app', async () => {
bootFixtureApp()
const projectLabel = await screen.findByText('fixture', {}, { timeout: 10_000 })
const projectRow = projectLabel.closest<HTMLElement>('[role="treeitem"]')
if (projectRow === null) throw new Error('fixture project row missing')
@@ -112,3 +117,53 @@ it('projects initial and revised durable titles through the built eight-plugin f
await expect(`${JSON.stringify({ initial, revised }, null, 2)}\n`)
.toMatchFileSnapshot('./snapshots/session-title.json')
})
it('snapshots pending and committed plan targets without implicit turn cancellation', async () => {
bootFixtureApp()
await screen.findByText('fixture', {}, { timeout: 10_000 })
fireEvent.click(screen.getByRole('button', { name: 'New session' }))
const select: HTMLSelectElement = await screen.findByRole('combobox', { name: '协作模式' }, { timeout: 10_000 })
const capture = (stage: string) => {
const chip = select.parentElement?.querySelector('span')?.textContent ?? ''
return {
stage,
label: chip,
value: select.value,
title: select.parentElement?.getAttribute('title') ?? '',
primary: screen.getByRole('button', { name: /^(发送|停止)$/ }).getAttribute('aria-label'),
}
}
const initial = capture('initial')
fireEvent.change(select, { target: { value: 'plan' } })
await screen.findByTitle(/计划模式将在下一次模型请求时生效/)
const planPending = capture('plan-pending')
const input = screen.getByPlaceholderText(/输入消息/)
fireEvent.change(input, { target: { value: 'commit plan' } })
fireEvent.click(screen.getByRole('button', { name: '发送' }))
await screen.findByTitle('当前为计划模式')
const planCommitted = capture('plan-committed')
fireEvent.change(select, { target: { value: 'default' } })
await screen.findByTitle(/默认模式将在下一次模型请求时生效/)
const defaultPendingWhileRunning = capture('default-pending-running')
fireEvent.click(screen.getByRole('button', { name: '停止' }))
await waitFor(() => { expect(screen.getByRole('button', { name: '发送' })).toBeTruthy() })
const defaultPendingAfterStop = capture('default-pending-stopped')
fireEvent.change(input, { target: { value: 'commit default' } })
fireEvent.click(screen.getByRole('button', { name: '发送' }))
await screen.findByTitle('当前为默认模式')
const defaultCommitted = capture('default-committed')
fireEvent.click(screen.getByRole('button', { name: '停止' }))
await expect(`${JSON.stringify({
initial,
planPending,
planCommitted,
defaultPendingWhileRunning,
defaultPendingAfterStop,
defaultCommitted,
}, null, 2)}\n`).toMatchFileSnapshot('./snapshots/plan-mode.json')
})
+28 -2
View File
@@ -1,7 +1,7 @@
// Keyless boot-chain smoke over the REAL carrier: startWebServer + entry
// graph (__DSH_BOOT__ web2 shape) injection + built shell dist in a real
// chromium. First describe: graph injection + the fail-loud half. Second
// describe: the settled success pass — all nine REAL tsdown bundles load
// describe: the settled success pass — all ten REAL tsdown bundles load
// through the module system + vendored Loader chain in ?fixture mode (the
// infrastructure four ride the immediately prefetch tier, the UI rows fetch
// on demand), the three-column frame appears in one flip, and the resident
@@ -31,6 +31,7 @@ const REAL_PLUGINS: { id: string; dir: string; inject?: string[]; immediately?:
{ id: LAYOUT_ID, dir: 'ui-layout', inject: ['@deepseek-ai/dsh-client-runtime'] },
{ id: SIDEBAR_ID, dir: 'ui-sidebar', inject: [LAYOUT_ID] },
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', inject: [LAYOUT_ID] },
{ id: '@deepseek-ai/dsh-client-ui-plan', dir: 'ui-plan', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
{ id: '@deepseek-ai/dsh-client-ui-question', dir: 'ui-question', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
{ id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
]
@@ -118,7 +119,7 @@ describe('web boot chain (keyless, real carrier)', () => {
})
})
describe('web boot chain success pass (keyless, nine real bundles, ?fixture)', () => {
describe('web boot chain success pass (keyless, ten real bundles, ?fixture)', () => {
let server: Awaited<ReturnType<typeof startWebServer>>
let browser: Browser
let page: Page
@@ -246,6 +247,31 @@ describe('web boot chain success pass (keyless, nine real bundles, ?fixture)', (
expect(await external.getAttribute('rel')).toBe('noopener noreferrer')
})
it('queues plan targets without cancelling a running turn and commits them at prompt boundaries', async () => {
onTestFailed(() => saveFailureShot(page, 'smoke-plan-mode'))
const select = page.getByRole('combobox', { name: '协作模式' })
await select.waitFor({ state: 'visible', timeout: 15_000 })
await expect(page.getByTitle('当前为默认模式').isVisible()).resolves.toBe(true)
await select.selectOption('plan')
await page.getByTitle(/计划模式将在下一次模型请求时生效/).waitFor()
const input = page.locator('textarea[placeholder]')
await input.fill('commit plan mode')
await page.getByRole('button', { name: '发送' }).click()
await page.getByTitle('当前为计划模式').waitFor()
await select.selectOption('default')
await page.getByTitle(/默认模式将在下一次模型请求时生效/).waitFor()
await expect(page.getByRole('button', { name: '停止' }).isVisible()).resolves.toBe(true)
await page.getByRole('button', { name: '停止' }).click()
await page.getByTitle(/默认模式将在下一次模型请求时生效/).waitFor()
await input.fill('commit default mode')
await page.getByRole('button', { name: '发送' }).click()
await page.getByTitle('当前为默认模式').waitFor()
await page.getByRole('button', { name: '停止' }).click()
})
it('renders and completes the resident question through the composer slot', async () => {
onTestFailed(() => saveFailureShot(page, 'smoke-question-composer'))
const sessionTree = page.getByRole('tree', { name: 'Sessions' })
+17 -3
View File
@@ -144,10 +144,13 @@ async function detailsTrack(page: Page): Promise<number> {
return Number(cols.split(' ').pop()!.replace('px', ''))
}
// Readiness gate: `dsh web` serves ALL nine manifest plugins; until every UI
// Readiness gate: `dsh web` serves ALL ten manifest plugins; until every UI
// plugin's client bundle exists and exports apply, the loader fail-louds and
// the frame never appears.
const UI_PLUGIN_DIRS = ['connection', 'runtime', 'ui-theme', 'i18n', 'ui-layout', 'ui-sidebar', 'ui-conversation', 'ui-question', 'ui-trajectory']
const UI_PLUGIN_DIRS = [
'connection', 'runtime', 'ui-theme', 'i18n', 'ui-layout',
'ui-sidebar', 'ui-conversation', 'ui-plan', 'ui-question', 'ui-trajectory',
]
const ROUND_DONE_MARKER = 'WEB_ROUND_DONE'
const notReady = UI_PLUGIN_DIRS.filter((dir) => {
const bundle = join(REPO_ROOT, 'packages/client', dir, 'lib/client.js')
@@ -177,6 +180,9 @@ describe('dsh web keyless CLI smoke', () => {
const readyUrl = await waitForReadyLine(child)
expect(readyUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/)
expect((await fetch(readyUrl)).status).toBe(200)
const created = await rpc<{ sessionId: string }>(readyUrl, 'session.create', {})
expect(await rpc(readyUrl, 'session.planMode', { sessionId: created.sessionId }))
.toEqual({ active: false })
} finally {
const closed = child.exitCode === null
? new Promise<void>((resolveClose) => { child.once('close', () => { resolveClose() }) })
@@ -187,7 +193,7 @@ describe('dsh web keyless CLI smoke', () => {
}
})
it('injects the invoking workspace AGENTS.md into the provider request', async () => {
it('injects workspace instructions and the active Web plan policy into the provider request', async () => {
requireDist()
const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-workspace-'))
mkdirSync(join(workspace, '.git'))
@@ -235,6 +241,9 @@ describe('dsh web keyless CLI smoke', () => {
try {
const baseUrl = await waitForReadyLine(child)
const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {})
expect(await rpc(baseUrl, 'session.setPlanMode', {
sessionId: created.sessionId, active: true,
})).toEqual({ active: false, pending: true })
await rpc<{ accepted: true }>(baseUrl, 'session.prompt', {
sessionId: created.sessionId,
mode: 'queue',
@@ -248,6 +257,11 @@ describe('dsh web keyless CLI smoke', () => {
])
const workspaceMessage = captured.messages?.find(message =>
message.role === 'user' && message.content?.includes('web-workspace-context-probe'))
const systemMessage = captured.messages?.find(message => message.role === 'system')
expect(systemMessage?.content).toContain('Stay in plan mode until exit_plan_mode succeeds')
expect(systemMessage?.content).toContain('Do not edit or write files')
expect(await rpc(baseUrl, 'session.planMode', { sessionId: created.sessionId }))
.toEqual({ active: true })
expect(workspaceMessage).toMatchInlineSnapshot(`
{
"content": "<system-reminder>
+44
View File
@@ -0,0 +1,44 @@
{
"initial": {
"stage": "initial",
"label": "默认",
"value": "default",
"title": "当前为默认模式",
"primary": "发送"
},
"planPending": {
"stage": "plan-pending",
"label": "计划 · 待生效",
"value": "plan",
"title": "当前为默认模式;计划模式将在下一次模型请求时生效",
"primary": "发送"
},
"planCommitted": {
"stage": "plan-committed",
"label": "计划",
"value": "plan",
"title": "当前为计划模式",
"primary": "停止"
},
"defaultPendingWhileRunning": {
"stage": "default-pending-running",
"label": "默认 · 待生效",
"value": "default",
"title": "当前为计划模式;默认模式将在下一次模型请求时生效",
"primary": "停止"
},
"defaultPendingAfterStop": {
"stage": "default-pending-stopped",
"label": "默认 · 待生效",
"value": "default",
"title": "当前为计划模式;默认模式将在下一次模型请求时生效",
"primary": "发送"
},
"defaultCommitted": {
"stage": "default-committed",
"label": "默认",
"value": "default",
"title": "当前为默认模式",
"primary": "停止"
}
}
+1
View File
@@ -1898,6 +1898,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
- `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts))
- `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts))
- `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts))
- `@deepseek-ai/dsh-client-ui-plan` — requires `tools` · `systemPrompt` ([`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts))
- `@deepseek-ai/dsh-client-ui-question` — requires `tools` · `userInteraction` ([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts))
- `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts))
- `@deepseek-ai/dsh-client-ui-theme` ([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts))
@@ -9,7 +9,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
import type {
ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt,
RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary,
PlanModeState, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary,
ToolCallView, ToolEventView, ToolResultView,
} from './api.ts'
import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api'
@@ -296,6 +296,9 @@ export function createFixtureApi(): ApiProxy {
]
const logs = new Map<SessionId, SessionEvent[]>([[sid('fx-alpha'), buildAlphaLog()]])
const nextTurn = new Map<SessionId, number>([[sid('fx-alpha'), 60]])
const planStates = new Map<SessionId, PlanModeState>(
sessions.map(session => [session.sessionId, { active: false }]),
)
let nextSession = 1
let nextRpc = 1
const mint = (): ReturnType<typeof RpcId> => RpcId(`fx-rpc-${nextRpc++}`)
@@ -469,6 +472,7 @@ export function createFixtureApi(): ApiProxy {
sessionId: sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, cwd: '/tmp/fixture',
}
sessions.push(created)
planStates.set(created.sessionId, { active: false })
emitHost({ type: 'host/session-added', sessionId: created.sessionId })
return ok(request, { sessionId: created.sessionId })
},
@@ -502,6 +506,12 @@ export function createFixtureApi(): ApiProxy {
nextTurn.set(id, turn + 1)
setRunning(id, true)
append(id, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
const planState = planStates.get(id)
if (planState?.pending !== undefined) {
const active = planState.pending
planStates.set(id, { active })
if (active !== planState.active) append(id, { type: 'plan/mode', data: { active } })
}
append(id, { type: 'user/message', surfaceOp: 'append', data: { content, source: { kind: 'user' } } })
startReply(
id,
@@ -522,8 +532,32 @@ export function createFixtureApi(): ApiProxy {
}
return ok(request, { accepted: true as const })
},
planMode: request => ok(request, null),
setPlanMode: request => ok(request, null),
planMode: (request) => {
const state = planStates.get(request.payload.sessionId)
return state === undefined
? err(request, {
code: 'session-not-found',
message: `no session ${request.payload.sessionId}`,
details: { sessionId: request.payload.sessionId },
})
: ok(request, { ...state })
},
setPlanMode: (request) => {
const state = planStates.get(request.payload.sessionId)
if (state === undefined) {
return err(request, {
code: 'session-not-found',
message: `no session ${request.payload.sessionId}`,
details: { sessionId: request.payload.sessionId },
})
}
const target = state.pending ?? state.active
const next = request.payload.active === target
? state
: { active: state.active, pending: request.payload.active }
planStates.set(request.payload.sessionId, next)
return ok(request, { ...next })
},
},
host: {
describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions: 1 }),
@@ -48,13 +48,53 @@ describe('createFixtureApi', () => {
expect(response.result.value.items[1]?.parentSessionId).toBe('fx-alpha') // lineage material
})
it('reports plan mode as an unavailable optional fixture capability', async () => {
it('queues fixture plan selections, commits them at prompt boundaries, and preserves pending state on cancel', async () => {
const client = new FixtureApiClient()
expect((await client.sessions.planMode({ sessionId: sid('fx-alpha') })).result).toEqual({
ok: true, value: null,
ok: true, value: { active: false },
})
expect((await client.sessions.setPlanMode({ sessionId: sid('fx-alpha'), active: true })).result).toEqual({
ok: true, value: null,
ok: true, value: { active: false, pending: true },
})
expect((await client.sessions.cancel({ sessionId: sid('fx-alpha') })).result).toEqual({
ok: true, value: { accepted: true },
})
expect((await client.sessions.planMode({ sessionId: sid('fx-alpha') })).result).toEqual({
ok: true, value: { active: false, pending: true },
})
await client.sessions.prompt({
sessionId: sid('fx-alpha'), mode: 'queue', content: [{ type: 'text', text: 'commit plan' }],
})
expect((await client.sessions.planMode({ sessionId: sid('fx-alpha') })).result).toEqual({
ok: true, value: { active: true },
})
expect((await client.sessions.setPlanMode({ sessionId: sid('fx-alpha'), active: false })).result).toEqual({
ok: true, value: { active: true, pending: false },
})
expect((await client.sessions.planMode({ sessionId: sid('missing') })).result).toMatchObject({
ok: false, error: { code: 'session-not-found' },
})
expect((await client.sessions.setPlanMode({ sessionId: sid('missing'), active: true })).result).toMatchObject({
ok: false, error: { code: 'session-not-found' },
})
const created = await client.sessions.create({})
if (!created.result.ok) throw new Error('create failed')
const createdId = created.result.value.sessionId
await client.sessions.setPlanMode({ sessionId: createdId, active: true })
expect((await client.sessions.setPlanMode({ sessionId: createdId, active: false })).result).toEqual({
ok: true, value: { active: false, pending: false },
})
// Selecting the effective target again is a no-op, and a net-zero pending
// target clears at the boundary without a redundant plan/mode event.
expect((await client.sessions.setPlanMode({ sessionId: createdId, active: false })).result).toEqual({
ok: true, value: { active: false, pending: false },
})
await client.sessions.prompt({
sessionId: createdId, mode: 'queue', content: [{ type: 'text', text: 'remain default' }],
})
expect((await client.sessions.planMode({ sessionId: createdId })).result).toEqual({
ok: true, value: { active: false },
})
})
@@ -10,6 +10,8 @@ Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.to
Per-session UI state (selection, composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to the conversation, chat-view, and details registrations, so the session slots share one instance per session (selection written by the chat view, read by details) and the framework owns instance lifecycle and draft persistence. Components are pure — the framework standard kit (`useSession`/`sessionId`/`useSessions`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; the inject factories contribute plain data and callbacks only (send/stop choreography, tab read face, details/paging callbacks, startSession chain).
The default composer's bottom row exposes the session-scoped `'conversation.composer.controls'` list slot to the left of the primary action. Mode and policy features contribute controls through that slot; whole-composer takeovers such as questions remain selector-routed entries of the separate `'conversation.composer'` chain.
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).
## Model Experience
@@ -69,11 +69,12 @@ export function apply(ctx: Context): void {
// ConversationRoot is the only component authorized to render the ring.
slots.register({
name: 'conversation',
// The composer chain rides the same declaration table: takeover plugins
// register selector-routed replacements of the InputBar.
// Composer controls are additive bottom-row entries; the chain carries
// selector-routed replacements of the whole InputBar.
children: {
'conversation.view': { kind: 'list', scope: 'session' },
'conversation.composer': { kind: 'chain', scope: 'session' },
'conversation.composer.controls': { kind: 'list', scope: 'session' },
},
store: chatStore,
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ConversationInjected => {
@@ -41,6 +41,11 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* zero owner changes.
*/
'conversation.composer': { kind: 'chain'; scope: 'session'; owner: ComposerChainProps }
/**
* Additive controls in the default composer's bottom-left row. Entries
* receive the session standard kit and no owner payload.
*/
'conversation.composer.controls': { kind: 'list'; scope: 'session'; owner: ComposerControlOwnerProps }
}
}
@@ -127,9 +132,17 @@ export interface ComposerChainProps {
interactions: readonly PendingInteraction[]
}
/** Full conversation-slot component props: runtime & child-render (view ring + composer chain) & store & injected shares. */
/** Owner seat for additive composer controls; the render site supplies no payload. */
export interface ComposerControlOwnerProps {}
/** Full props of an additive composer-control entry. */
export type ComposerControlProps = PropsRuntime<'conversation.composer.controls'>
/** Full conversation-slot component props: runtime & child-render (view ring, controls, composer chain) & store & injected shares. */
export type ConversationSlotProps =
PropsRuntime<'conversation'> & PropsRenderSlots<'conversation.view' | 'conversation.composer'>
PropsRuntime<'conversation'> & PropsRenderSlots<
'conversation.view' | 'conversation.composer' | 'conversation.composer.controls'
>
& PropsStore<ChatStore> & ConversationInjected
/**
@@ -18,7 +18,8 @@ export type {
} from './contract/views.ts'
export type { ToolCallBlock } from './contract/tool-call-model.ts'
export type {
ChatStore, ChatViewInjected, ChatViewSlotProps, ComposerChainProps, ConversationInjected,
ChatStore, ChatViewInjected, ChatViewSlotProps, ComposerChainProps, ComposerControlOwnerProps,
ComposerControlProps, ConversationInjected,
ConversationSlotProps, ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
EmptyStateInjected, EmptyStateSlotProps, ToolRowOwnerProps, ToolRowProps,
} from './contract/slots.ts'
@@ -2,8 +2,8 @@
// Tab_Group + view area + composer). Pure component — everything arrives via
// props: the framework standard kit (useSession/sessionId/useSessions), the
// declared chat store's useStore/actions, the injected business face, and the
// renderSlot share for the declared 'conversation.view' child slot (views are
// slot entries; the active one renders via the list `only` filter) plus the
// renderSlot share for the declared view and composer-control child slots
// (views are slot entries; the active one renders via the list `only` filter) plus the
// renderSlotChain share for the 'conversation.composer' takeover chain.
// Breadcrumbs derive from useSessions with a pure parentId walk; the active
// view id lives in the chat store's `view` field (per-session by store scope).
@@ -58,6 +58,7 @@ export function ConversationRoot({
const error: InputBarError | null = promptError === null
? null
: { op: promptError.op, message: `${promptError.error.message}${promptError.error.code}` }
const controls = renderSlot('conversation.composer.controls', {})
// The default composer doubles as the chain's all-decline fallback: a
// pending wait with no registered takeover must still leave the input usable.
@@ -68,6 +69,7 @@ export function ConversationRoot({
disabled={removed}
error={error}
variant="composer"
controls={controls}
onDraftChange={actions.setDraft}
onSend={(mode) => { send(draft, mode) }}
onStop={stop}
@@ -119,12 +119,19 @@
min-height: 84px;
}
/* figma Frame 1123 (34:11463): pad 12/0/10/10, buttons vertically centered. */
/* figma Frame 1123 (34:11463): session controls sit bottom-left and the
primary action bottom-right. */
.row {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0 10px 10px 12px;
justify-content: space-between;
padding: 0 10px 10px 16px;
}
.controls {
display: flex;
align-items: center;
gap: 8px;
}
/* Primary send (figma IconButton 34:10465): 34px circle, #3964FE light /
@@ -26,13 +26,15 @@ export interface InputBarProps {
placeholder?: string
/** Optional leading accessory row content (the empty state mounts its cwd picker here). */
accessory?: ReactNode
/** Optional bottom-row controls, left of the primary button. */
controls?: ReactNode
onDraftChange: (text: string) => void
onSend: (mode: 'queue' | 'steer') => void
onStop: () => void
}
export function InputBar({
draft, running, disabled, error, variant, placeholder, accessory, onDraftChange, onSend, onStop,
draft, running, disabled, error, variant, placeholder, accessory, controls, onDraftChange, onSend, onStop,
}: InputBarProps) {
const empty = draft.trim() === ''
const inputRef = useRef<HTMLTextAreaElement | null>(null)
@@ -116,6 +118,7 @@ export function InputBar({
<div aria-hidden className={css.mirror}>{`${draft}\n`}</div>
</div>
<div className={css.row}>
<div className={css.controls}>{controls}</div>
<button
type="button"
className={clsx(css.primary, running && css.stopping)}
@@ -82,6 +82,7 @@ describe('apply wiring', () => {
// Declaring is claiming: the chat entry's registration put the hole on
// the ledger with the contract's kind/scope.
expect(b.slots.spec('conversation.chat.toolview')).toEqual({ kind: 'keyed', scope: 'session' })
expect(b.slots.spec('conversation.composer.controls')).toEqual({ kind: 'list', scope: 'session' })
})
it('occupies the three slots + the ring; session entries share one store handle, empty declares none', async () => {
@@ -122,6 +123,7 @@ describe('apply wiring', () => {
expect(b.slots.entries('conversation.view')).toHaveLength(0)
expect(b.slots.entries('conversation.chat.toolview')).toHaveLength(0)
expect(b.slots.spec('conversation.chat.toolview')).toBeUndefined()
expect(b.slots.spec('conversation.composer.controls')).toBeUndefined()
expect(b.slots.entries('details')).toHaveLength(0)
expect(b.slots.entries('conversation.empty')).toHaveLength(0)
expect(b.ctx.get('conversation')).toBeUndefined()
@@ -128,4 +128,11 @@ describe('error strip and variants', () => {
expect(view.getByTestId('acc')).toBeTruthy()
expect(view.container.querySelector('[class*="hero"]')).not.toBeNull()
})
it('renders additive controls left of the primary action', () => {
const { view, button } = setup({ controls: <span data-testid="mode-control"></span> })
const control = view.getByTestId('mode-control')
expect(control).toBeTruthy()
expect(control.compareDocumentPosition(button) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
})
})
@@ -58,7 +58,9 @@ function listHook(rows: { id: string; title: string; cwd?: string; parentId?: st
describe('ConversationRoot branches', () => {
const chatTab: ViewTab = { id: 'chat', label: 'Chat' }
/** renderSlot stub in the outlet's baked shape (ring key + only filter marker). */
const stubRenderSlot = (() => <div data-testid="view-body" />) as unknown as ConversationRootProps['renderSlot']
const stubRenderSlot = ((key: string) => key === 'conversation.composer.controls'
? null
: <div data-testid="view-body" />) as unknown as ConversationRootProps['renderSlot']
/** SessionProvider seat stub (render-prop pass-through; ConversationRoot never invokes it). */
const SessionProviderStub: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>
@@ -119,9 +119,9 @@ describe('ConversationRoot', () => {
// The renderSlot share as the outlet would bake it: renders a marker for
// the ring key carrying the active-id filter (a Mock cannot satisfy the
// generic method type directly — cast once at the prop seam).
const renderSlot = vi.fn((key: string, _owner: object, opts?: { only?: string }) => (
<div data-testid={`view-${opts?.only ?? '(all)'}`} data-slot={key} />
))
const renderSlot = vi.fn((key: string, _owner: object, opts?: { only?: string }) => key === 'conversation.composer.controls'
? <span data-testid="composer-controls" />
: <div data-testid={`view-${opts?.only ?? '(all)'}`} data-slot={key} />)
const ui = render(
<ConversationRoot
sessionId={sid('s1')}
@@ -173,7 +173,9 @@ describe('ConversationRoot', () => {
const { renderSlot } = bench([tab('chat', 'Chat')])
// No owner share: views take everything from the standard kit (contract).
expect(renderSlot).toHaveBeenCalledWith('conversation.view', {}, { only: 'chat' })
expect(renderSlot).toHaveBeenCalledWith('conversation.composer.controls', {})
expect(screen.getByTestId('view-chat').getAttribute('data-slot')).toBe('conversation.view')
expect(screen.getByTestId('composer-controls')).toBeTruthy()
})
it('hides the tab strip with a single view; composer writes the store draft and sends it', () => {
@@ -195,6 +197,7 @@ describe('ConversationRoot', () => {
bench([tab('chat', 'Chat')], undefined, { pending: [wait] }, renderSlotChain)
expect(screen.getByText('question takeover')).toBeTruthy()
expect(screen.queryByPlaceholderText(/输入消息/)).toBeNull()
expect(screen.queryByTestId('composer-controls')).toBeNull()
// The owner dispatches the raw pending list (chain currency); routing
// lives in entry selectors, not here.
expect(renderSlotChain).toHaveBeenCalledWith(
+21
View File
@@ -0,0 +1,21 @@
# @deepseek-ai/dsh-client-ui-plan
Web plan-mode feature with two lifecycle-coupled halves. The node entry mounts `@deepseek-ai/dsh-plan-mode` with the Web product policy; the browser entry contributes a session-scoped selector to `conversation.composer.controls`.
The selector distinguishes unavailable capability (`planMode === null`), committed mode (`active`), and the target queued for the next model-request boundary (`pending`, including `pending: false`). Selecting a mode never cancels a running turn. It remains available while generation is running, disables only during its own RPC, and displays the host-confirmed pending target until a logged `plan/mode` event commits it.
The model exits plan mode through the stable `exit_plan_mode` tool. Its plan review uses the composed Web question channel: approval schedules default mode for the next step, while rejection or custom feedback keeps plan mode active and returns the feedback to the model.
## Model Experience
Indirectly, through `@deepseek-ai/dsh-plan-mode`; that package owns policy activation, the exit-tool schema and rendering, logged state, and request-boundary transitions, while this package supplies the Web composition's section text.
#### KV Cache effect
Entering or leaving plan mode changes the active system-prompt section and therefore the request prefix. The stable exit-tool registration avoids an additional tool-catalog shape change across the same transition.
## Known Limitations and Deferred Work
- **Plan mode is guidance, not an execution sandbox** — deployments that require enforced read-only planning must compose the independent sandbox and approval policies.
- **The control belongs to the default composer** — a pending whole-composer interaction such as plan review temporarily replaces the InputBar and its mode control.
- **An idle pending target is process-local until the next boundary** — a process exit before another prompt loses that uncommitted intent; the committed mode remains durable in the session log.
+65
View File
@@ -0,0 +1,65 @@
{
"name": "@deepseek-ai/dsh-client-ui-plan",
"description": "Web plan-mode feature: host policy mount plus a pending-aware composer control",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-plan-mode": "workspace:^",
"react": "^18.2.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
]
}
@@ -0,0 +1,58 @@
/* Quiet composer chip: an invisible native select owns interaction and
accessibility while the visible layer reports committed/pending state. */
.wrap {
display: inline-flex;
align-items: center;
gap: 6px;
}
.root {
position: relative;
display: inline-flex;
align-items: center;
}
.chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 8px;
border-radius: 8px;
color: var(--dsw-alias-label-secondary);
font-size: 14px;
line-height: 20px;
pointer-events: none;
}
.root:hover .chip {
background: var(--dsw-alias-interactive-bg-hover);
}
.chevron {
color: var(--dsw-alias-label-caption);
}
.select {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
border: none;
cursor: pointer;
}
.select:disabled {
cursor: default;
}
.root:has(.select:disabled) .chip {
opacity: 0.5;
}
.error {
color: var(--dsw-alias-state-error-primary);
font-size: 12px;
line-height: 18px;
}
@@ -0,0 +1,74 @@
import { useEffect, useRef, useState } from 'react'
import type { PlanModeControlProps } from './index.ts'
import css from './PlanModeControl.module.css'
const labels = {
default: '默认',
plan: '计划',
} as const
/** Composer control for the host-confirmed plan target. */
export function PlanModeControl({ useSession, setPlanMode }: PlanModeControlProps) {
const planMode = useSession(snapshot => snapshot.planMode)
const [switching, setSwitching] = useState(false)
const [error, setError] = useState<string | null>(null)
const aliveRef = useRef(true)
useEffect(() => {
aliveRef.current = true
return () => {
aliveRef.current = false
}
}, [])
if (planMode === null) return null
const pending = planMode.pending !== undefined
const target = planMode.pending ?? planMode.active
const value = target ? 'plan' : 'default'
const currentLabel = labels[planMode.active ? 'plan' : 'default']
const targetLabel = labels[value]
const label = `${targetLabel}${pending ? ' · 待生效' : ''}`
const title = pending
? `当前为${currentLabel}模式;${targetLabel}模式将在下一次模型请求时生效`
: `当前为${currentLabel}模式`
const select = (active: boolean): void => {
if (active === target || switching) return
setSwitching(true)
setError(null)
void setPlanMode(active).then((failure) => {
if (!aliveRef.current) return
setSwitching(false)
setError(failure)
}, (reason: unknown) => {
if (!aliveRef.current) return
setSwitching(false)
setError(reason instanceof Error ? reason.message : String(reason))
})
}
return (
<span className={css.wrap}>
<label className={css.root} title={title}>
<span className={css.chip}>
{label}
<svg className={css.chevron} viewBox="0 0 12 12" width="12" height="12" aria-hidden>
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" />
</svg>
</span>
<select
className={css.select}
aria-label="协作模式"
value={value}
disabled={switching}
onChange={(event) => { select(event.target.value === 'plan') }}
>
<option value="default"></option>
<option value="plan"></option>
</select>
</label>
{error !== null && <span className={css.error} role="status" title={error}></span>}
</span>
)
}
@@ -0,0 +1,46 @@
/**
* Web plan plugin, browser half: contributes one pending-aware selector to
* the default composer's additive controls slot.
*/
import type {
ClientContext, SessionId, SessionsService,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ComposerControlProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { PlanModeControl } from './PlanModeControl.tsx'
/** Callback share injected into the pure control component. */
export interface PlanModeControlInjected {
/** Select the target mode; null means success, a string is user-visible failure detail. */
setPlanMode(active: boolean): Promise<string | null>
}
/** Complete props assembled for the composer-control entry. */
export type PlanModeControlProps = ComposerControlProps & PlanModeControlInjected
/**
* Required services. `conversation` is the ordering edge that guarantees the
* composer-controls slot has been declared before this plugin registers.
*/
export const inject = ['slots', 'sessions', 'conversation']
/**
* Register the plan selector and bridge its callback to the session object.
* @param ctx - Client root context.
*/
export function apply(ctx: ClientContext): void {
// This dual-half package also imports the host plan service, whose program
// carries the host-side `sessions` merge. Resolve and narrow the browser
// service at the client entry seam instead of relying on that shared key.
const sessions = ctx.get('sessions') as unknown as SessionsService
ctx.slots.register({
name: 'conversation.composer.controls',
id: 'plan-mode',
order: 10,
inject: (sessionId: SessionId): PlanModeControlInjected => ({
setPlanMode: async (active) => {
const result = await sessions.manager.get(sessionId).setPlanMode(active)
return result.ok ? null : `${result.error.message}${result.error.code}`
},
}),
}, PlanModeControl)
}
+4
View File
@@ -0,0 +1,4 @@
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}
+30
View File
@@ -0,0 +1,30 @@
/**
* Web plan plugin, node half: selecting this UI feature also mounts the
* logged plan-mode service with the Web product's planning policy.
*/
import type { Context } from 'cordis'
import PlanModeService from '@deepseek-ai/dsh-plan-mode'
/** Host services required by plan mode. */
export const inject = ['tools', 'systemPrompt']
/** Web product-owned policy rendered while plan mode is active. */
export const WEB_PLAN_SECTION = `You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode.
Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery.
The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed only to keep the request shape stable. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode.
Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out.
Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions.
When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation.`
/**
* Mount plan mode for hosts that selected the Web plan plugin.
* @param ctx - Host context carrying tools and systemPrompt.
*/
export function apply(ctx: Context): void {
ctx.plugin(PlanModeService, { section: WEB_PLAN_SECTION })
}
+31
View File
@@ -0,0 +1,31 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-plan`.
* @module @deepseek-ai/dsh-client-ui-plan/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-plan'
/** Cordis companion plugin name. */
export const name = 'client-ui-plan-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: plan state and boundary ownership are
* audited by dsh-plan-mode, while the control is a slot effect whose
* declaration, registration, and teardown are exercised by this package.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns The installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */
@@ -0,0 +1,58 @@
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { PlanModeControl } from '../src/client/PlanModeControl.tsx'
import type { PlanModeControlInjected } from '../src/client/index.ts'
import { apply, inject } from '../src/client/index.ts'
const SID = 's-plan' as SessionId
async function bench() {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
const slots = ctx.get('slots') as SlotsService
slots.register({
name: 'root',
children: { 'conversation.composer.controls': { kind: 'list', scope: 'session' } },
} as never, () => null)
const setPlanMode = vi.fn(() => Promise.resolve({ ok: true, value: { active: false, pending: true } }))
ctx.provide('sessions', { manager: { get: () => ({ setPlanMode }) } })
ctx.provide('conversation', {})
return { ctx, slots, setPlanMode }
}
describe('ui-plan browser apply', () => {
it('declares every service it binds', () => {
expect(inject).toEqual(['slots', 'sessions', 'conversation'])
})
it('fails loud when conversation did not declare the controls slot', async () => {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
ctx.provide('sessions', {})
ctx.provide('conversation', {})
await expect(ctx.plugin({ inject: [...inject], apply }))
.rejects.toThrow(/slot "conversation.composer.controls" is not declared/)
})
it('registers the control, bridges host results, and unregisters on teardown', async () => {
const b = await bench()
const fiber = b.ctx.plugin({ inject: [...inject], apply })
await fiber.await()
const entry = b.slots.entries('conversation.composer.controls')[0]!
expect(entry.component).toBe(PlanModeControl)
expect(entry.options).toMatchObject({ id: 'plan-mode', order: 10 })
const injected = (entry.inject as unknown as (id: SessionId) => PlanModeControlInjected)(SID)
await expect(injected.setPlanMode(true)).resolves.toBeNull()
expect(b.setPlanMode).toHaveBeenCalledWith(true)
b.setPlanMode.mockResolvedValueOnce({
ok: false, error: { code: 'session-not-found', message: 'gone', details: {} },
} as never)
await expect(injected.setPlanMode(false)).resolves.toBe('gonesession-not-found')
await fiber.dispose()
expect(b.slots.entries('conversation.composer.controls')).toHaveLength(0)
})
})
@@ -0,0 +1,39 @@
import { Context } from 'cordis'
import { afterEach, describe, expect, it } from 'vitest'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import { EXIT_PLAN_MODE } from '@deepseek-ai/dsh-plan-mode'
import { WEB_PLAN_SECTION, apply, inject } from '../src/index.ts'
let ctx: Context | undefined
afterEach(async () => {
await ctx?.fiber.dispose()
ctx = undefined
})
describe('ui-plan node plugin', () => {
it('mounts the Web policy and stable exit tool for the selected feature lifecycle', async () => {
ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(UserInteractionService)
const feature = ctx.plugin({ inject: [...inject], apply })
await feature.await()
expect(ctx.get('planMode')).toBeDefined()
expect(ctx.tools.get(EXIT_PLAN_MODE)).toBeDefined()
expect(WEB_PLAN_SECTION).toContain('Stay in plan mode until exit_plan_mode succeeds')
expect(WEB_PLAN_SECTION).toContain('Do not edit or write files')
expect(WEB_PLAN_SECTION).toContain('Make exit_plan_mode the only and final tool call')
expect((await ctx.systemPrompt.assemble()).sections)
.toEqual(expect.arrayContaining([expect.objectContaining({ name: 'plan:policy', text: '' })]))
await feature.dispose()
expect(ctx.get('planMode')).toBeUndefined()
expect(ctx.tools.get(EXIT_PLAN_MODE)).toBeUndefined()
expect((await ctx.systemPrompt.assemble()).sections.map(section => section.name))
.not.toContain('plan:policy')
})
})
@@ -0,0 +1,111 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import type {
ConversationSnapshot, SessionId, SessionListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { PlanModeState } from '@deepseek-ai/dsh-client-connection/client'
import { PlanModeControl } from '../src/client/PlanModeControl.tsx'
afterEach(cleanup)
const SID = 's-plan' as SessionId
function setup(
planMode: PlanModeState | null,
setPlanMode = vi.fn(() => Promise.resolve<string | null>(null)),
running = false,
) {
const store = createSnapshotStore({ planMode, running })
const useSession = bindSnapshotSelector(store) as unknown as SnapshotSelectorHook<ConversationSnapshot>
const useSessions = (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>
const view = render(
<PlanModeControl
sessionId={SID}
useSession={useSession}
useSessions={useSessions}
setPlanMode={setPlanMode}
/>,
)
return { store, setPlanMode, view }
}
describe('PlanModeControl', () => {
it('hides an unavailable capability and renders committed modes', () => {
const unavailable = setup(null)
expect(unavailable.view.container.innerHTML).toBe('')
cleanup()
setup({ active: false })
expect(screen.getByTitle('当前为默认模式')).toBeTruthy()
expect((screen.getByRole('combobox', { name: '协作模式' }) as HTMLSelectElement).value).toBe('default')
})
it('treats pending field presence as the target, including pending false', () => {
setup({ active: false, pending: true })
expect(screen.getByText('计划 · 待生效')).toBeTruthy()
expect(screen.getByTitle(/当前为默认模式/)).toBeTruthy()
cleanup()
setup({ active: true, pending: false })
expect(screen.getByText('默认 · 待生效')).toBeTruthy()
expect((screen.getByRole('combobox') as HTMLSelectElement).value).toBe('default')
})
it('switches from the effective target and remains available while a turn runs', async () => {
let resolve!: (value: string | null) => void
const setPlanMode = vi.fn(() => new Promise<string | null>((done) => { resolve = done }))
const { store } = setup({ active: false }, setPlanMode, true)
const select = screen.getByRole('combobox', { name: '协作模式' }) as HTMLSelectElement
expect(select.disabled).toBe(false)
fireEvent.change(select, { target: { value: 'plan' } })
expect(setPlanMode).toHaveBeenCalledWith(true)
expect(select.disabled).toBe(true)
store.set({ planMode: { active: false, pending: true }, running: true })
resolve(null)
await waitFor(() => {
expect((screen.getByRole('combobox') as HTMLSelectElement).disabled).toBe(false)
})
expect(screen.getByText('计划 · 待生效')).toBeTruthy()
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'plan' } })
expect(setPlanMode).toHaveBeenCalledTimes(1)
})
it('surfaces host and transport failures without changing the confirmed mode', async () => {
const setPlanMode = vi.fn()
.mockResolvedValueOnce('host said no')
.mockRejectedValueOnce(new Error('network down'))
.mockRejectedValueOnce('socket closed')
setup({ active: false }, setPlanMode)
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'plan' } })
expect((await screen.findByText('模式切换失败')).getAttribute('title')).toBe('host said no')
expect(screen.getByTitle('当前为默认模式')).toBeTruthy()
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'plan' } })
expect(await screen.findByTitle('network down')).toBeTruthy()
expect((screen.getByRole('combobox') as HTMLSelectElement).disabled).toBe(false)
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'plan' } })
expect(await screen.findByTitle('socket closed')).toBeTruthy()
})
it('ignores in-flight fulfillment and rejection after unmount', () => {
let resolve!: (value: string | null) => void
const successful = setup(
{ active: false },
vi.fn(() => new Promise<string | null>((done) => { resolve = done })),
)
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'plan' } })
successful.view.unmount()
expect(() => { resolve(null) }).not.toThrow()
let reject!: (reason: unknown) => void
const setPlanMode = vi.fn(() => new Promise<string | null>((_done, fail) => { reject = fail }))
const { view } = setup({ active: false }, setPlanMode)
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'plan' } })
view.unmount()
expect(() => { reject(new Error('late')) }).not.toThrow()
})
})
+45
View File
@@ -0,0 +1,45 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../runtime"
},
{
"path": "../connection"
},
{
"path": "../ui-conversation"
},
{
"path": "../ui-slots"
},
{
"path": "../web-react"
},
{
"path": "../../plan/plan-mode"
},
{
"path": "../../core/system-prompt"
},
{
"path": "../../core/tools"
},
{
"path": "../../ui/user-interaction"
},
{
"path": "../../support/invariants"
}
]
}
+3
View File
@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-ui-plan', ['lib/types/index.js', 'lib/types/invariant.js'])
+46
View File
@@ -119,6 +119,9 @@ importers:
'@deepseek-ai/dsh-client-ui-layout':
specifier: workspace:^
version: link:../../packages/client/ui-layout
'@deepseek-ai/dsh-client-ui-plan':
specifier: workspace:^
version: link:../../packages/client/ui-plan
'@deepseek-ai/dsh-client-ui-question':
specifier: workspace:^
version: link:../../packages/client/ui-question
@@ -665,6 +668,49 @@ importers:
specifier: ^18.2.0
version: 18.3.1
packages/client/ui-plan:
dependencies:
'@deepseek-ai/dsh-client-runtime':
specifier: workspace:^
version: link:../runtime
'@deepseek-ai/dsh-client-ui-conversation':
specifier: workspace:^
version: link:../ui-conversation
'@deepseek-ai/dsh-client-ui-slots':
specifier: workspace:^
version: link:../ui-slots
'@deepseek-ai/dsh-plan-mode':
specifier: workspace:^
version: link:../../plan/plan-mode
react:
specifier: ^18.2.0
version: 18.3.1
devDependencies:
'@deepseek-ai/dsh-client-connection':
specifier: workspace:^
version: link:../connection
'@deepseek-ai/dsh-client-web-react':
specifier: workspace:^
version: link:../web-react
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../support/invariants
'@deepseek-ai/dsh-system-prompt':
specifier: workspace:^
version: link:../../core/system-prompt
'@deepseek-ai/dsh-tools':
specifier: workspace:^
version: link:../../core/tools
'@deepseek-ai/dsh-user-interaction':
specifier: workspace:^
version: link:../../ui/user-interaction
'@types/react':
specifier: ~18.3.1
version: 18.3.31
cordis:
specifier: ^4.0.0-rc.7
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
packages/client/ui-primitives:
dependencies:
clsx:
@@ -55,6 +55,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/client/ui-layout': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-sidebar': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-conversation': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-plan': { kind: 'indirect', reason: 'The package mounts dsh-plan-mode; that package owns the model-visible policy and exit tool.' },
'packages/client/ui-question': { kind: 'indirect', reason: 'The package mounts dsh-tool-ask-user; that tool owns the model-visible schema and answer rendering.' },
'packages/client/ui-trajectory': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-theme': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
+1
View File
@@ -109,6 +109,7 @@
"@deepseek-ai/dsh-client-ui-layout": ["./packages/client/ui-layout/src"],
"@deepseek-ai/dsh-client-ui-sidebar": ["./packages/client/ui-sidebar/src"],
"@deepseek-ai/dsh-client-ui-conversation": ["./packages/client/ui-conversation/src"],
"@deepseek-ai/dsh-client-ui-plan": ["./packages/client/ui-plan/src"],
"@deepseek-ai/dsh-client-ui-question": ["./packages/client/ui-question/src"],
"@deepseek-ai/dsh-client-ui-trajectory": ["./packages/client/ui-trajectory/src"],
"@deepseek-ai/dsh-client-ui-theme": ["./packages/client/ui-theme/src"],
+1
View File
@@ -34,6 +34,7 @@
{ "path": "./packages/client/ui-layout" },
{ "path": "./packages/client/ui-sidebar" },
{ "path": "./packages/client/ui-conversation" },
{ "path": "./packages/client/ui-plan" },
{ "path": "./packages/client/ui-question" },
{ "path": "./packages/client/ui-trajectory" },
{ "path": "./packages/client/ui-theme" },