From 9a69feeec0a55abd04f9653d4c70a7d1eee92eec Mon Sep 17 00:00:00 2001 From: fz Date: Fri, 24 Jul 2026 12:55:23 +0800 Subject: [PATCH] feat(web): add plan mode controls --- .../2026-07-24-web-plan-mode.i18n.yaml | 6 + .../feature/2026-07-24-web-plan-mode.md | 59 ++++++++++ .../feature/2026-07-24-web-plan-mode.zh.md | 59 ++++++++++ apps/cli/package.json | 1 + apps/cli/src/web.ts | 1 + apps/cli/tsconfig.json | 3 + apps/web/tests/session-title.snapshot.ts | 57 ++++++++- apps/web/tests/smoke-fixture.e2e.ts | 30 ++++- apps/web/tests/smoke-real.e2e.ts | 20 +++- apps/web/tests/snapshots/plan-mode.json | 44 +++++++ docs/config-catalog.md | 1 + .../client/connection/src/client/fixture.ts | 40 ++++++- .../client/connection/tests/fixture.spec.ts | 46 +++++++- packages/client/ui-conversation/README.md | 2 + .../ui-conversation/src/client/apply.ts | 5 +- .../src/client/contract/slots.ts | 17 ++- .../ui-conversation/src/client/index.ts | 3 +- .../src/client/skeleton/ConversationRoot.tsx | 6 +- .../src/client/skeleton/InputBar.module.css | 13 +- .../src/client/skeleton/InputBar.tsx | 5 +- .../ui-conversation/tests/chat-apply.spec.tsx | 2 + .../ui-conversation/tests/input-bar.spec.tsx | 7 ++ .../tests/skeleton-branches.spec.tsx | 4 +- .../ui-conversation/tests/skeleton.spec.tsx | 9 +- packages/client/ui-plan/README.md | 21 ++++ packages/client/ui-plan/package.json | 65 ++++++++++ .../src/client/PlanModeControl.module.css | 58 +++++++++ .../ui-plan/src/client/PlanModeControl.tsx | 74 ++++++++++++ packages/client/ui-plan/src/client/index.ts | 46 ++++++++ packages/client/ui-plan/src/css-modules.d.ts | 4 + packages/client/ui-plan/src/index.ts | 30 +++++ packages/client/ui-plan/src/invariant.ts | 31 +++++ .../ui-plan/tests/browser-plugin.spec.ts | 58 +++++++++ .../client/ui-plan/tests/node-plugin.spec.ts | 39 ++++++ .../ui-plan/tests/plan-mode-control.spec.tsx | 111 ++++++++++++++++++ packages/client/ui-plan/tsconfig.json | 45 +++++++ packages/client/ui-plan/tsdown.config.ts | 3 + pnpm-lock.yaml | 46 ++++++++ .../verify-package-readme-model-experience.ts | 1 + tsconfig.base.json | 1 + tsconfig.client.json | 1 + 41 files changed, 1047 insertions(+), 27 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-24-web-plan-mode.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-24-web-plan-mode.md create mode 100644 .agents/notes/implemented/feature/2026-07-24-web-plan-mode.zh.md create mode 100644 apps/web/tests/snapshots/plan-mode.json create mode 100644 packages/client/ui-plan/README.md create mode 100644 packages/client/ui-plan/package.json create mode 100644 packages/client/ui-plan/src/client/PlanModeControl.module.css create mode 100644 packages/client/ui-plan/src/client/PlanModeControl.tsx create mode 100644 packages/client/ui-plan/src/client/index.ts create mode 100644 packages/client/ui-plan/src/css-modules.d.ts create mode 100644 packages/client/ui-plan/src/index.ts create mode 100644 packages/client/ui-plan/src/invariant.ts create mode 100644 packages/client/ui-plan/tests/browser-plugin.spec.ts create mode 100644 packages/client/ui-plan/tests/node-plugin.spec.ts create mode 100644 packages/client/ui-plan/tests/plan-mode-control.spec.tsx create mode 100644 packages/client/ui-plan/tsconfig.json create mode 100644 packages/client/ui-plan/tsdown.config.ts diff --git a/.agents/notes/implemented/feature/2026-07-24-web-plan-mode.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-web-plan-mode.i18n.yaml new file mode 100644 index 0000000000..6e3bbade75 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-web-plan-mode.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/feature/2026-07-24-web-plan-mode.md b/.agents/notes/implemented/feature/2026-07-24-web-plan-mode.md new file mode 100644 index 0000000000..42407198c0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-web-plan-mode.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-07-24-web-plan-mode.zh.md b/.agents/notes/implemented/feature/2026-07-24-web-plan-mode.zh.md new file mode 100644 index 0000000000..db865a26e9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-web-plan-mode.zh.md @@ -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 产品现在会在保留插件边界的同时,公开与现有终端和 ACP(Agent Client Protocol)产品组合相同的 plan 交互模型。选择该功能会增加一个宿主策略/工具所有者和一个浏览器槽入口;移除其 fiber 会同时移除二者。模型工具目录在模式切换期间保持稳定,但激活的系统提示词段会在 plan 边界发生变化,因此请求前缀也会改变。Plan mode 仍是引导机制,而非执行沙箱:需要强制只读规划的部署仍需组合彼此独立的沙箱策略和审批策略。 diff --git a/apps/cli/package.json b/apps/cli/package.json index d7942c1e2d..8eb808e9e6 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -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:^", diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 7518804ebb..1a687b6028 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -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 diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index cbc786d4c6..7c89fdb50a 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -53,6 +53,9 @@ { "path": "../../packages/client/ui-conversation" }, + { + "path": "../../packages/client/ui-plan" + }, { "path": "../../packages/client/ui-trajectory" }, diff --git a/apps/web/tests/session-title.snapshot.ts b/apps/web/tests/session-title.snapshot.ts index 78023d5455..6ee4a8cd11 100644 --- a/apps/web/tests/session-title.snapshot.ts +++ b/apps/web/tests/session-title.snapshot.ts @@ -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('#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('[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') +}) diff --git a/apps/web/tests/smoke-fixture.e2e.ts b/apps/web/tests/smoke-fixture.e2e.ts index 0726d14c8b..2a847eea40 100644 --- a/apps/web/tests/smoke-fixture.e2e.ts +++ b/apps/web/tests/smoke-fixture.e2e.ts @@ -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> 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' }) diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index 9cd34530be..a12cfb4392 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -144,10 +144,13 @@ async function detailsTrack(page: Page): Promise { 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((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": " diff --git a/apps/web/tests/snapshots/plan-mode.json b/apps/web/tests/snapshots/plan-mode.json new file mode 100644 index 0000000000..1978768813 --- /dev/null +++ b/apps/web/tests/snapshots/plan-mode.json @@ -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": "停止" + } +} diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 985e493184..fb033dd44e 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -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)) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 3f409a6eef..c6ce23a289 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.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([[sid('fx-alpha'), buildAlphaLog()]]) const nextTurn = new Map([[sid('fx-alpha'), 60]]) + const planStates = new Map( + sessions.map(session => [session.sessionId, { active: false }]), + ) let nextSession = 1 let nextRpc = 1 const mint = (): ReturnType => 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 }), diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 0b03949e46..ea71b706ef 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -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 }, }) }) diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index ebd7474298..fe0351d8e9 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -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 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 8c4a6dc6a1..78aa6043c8 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -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): ConversationInjected => { diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index baa26683ec..1b626626e3 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -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 & ConversationInjected /** diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 8cfebac81c..0ad413ebfe 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -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' diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 59346a557b..0b35f29467 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -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} diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 1139c3c9c1..f2c3e264d8 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -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 / diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index c5c8307ffc..3c07e3f039 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -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(null) @@ -116,6 +118,7 @@ export function InputBar({
{`${draft}\n`}
+
{controls}