From a39bd9035ecc90a10375f4bc5e89a6f03c3c48b4 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:39:50 +0800 Subject: [PATCH] feat(web): replace the plan select with a status chip and placeholder swap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan mode is entered through /plan only — the select control is retired. The conversation.input.plan seat (now right of the access-mode control) renders a read-only Plan chip while the projection's effective target is plan mode; its hover x executes /plan off, and the chip follows the folded target (appears on /plan immediately, disappears on /plan off) with frames correcting either way. While plan mode is targeted the composer textarea's placeholder switches to the plan-task wording — InputBar reads the same projection through the standard-kit useProjection (the TodoDock posture: a type-only key merge, no domain service edge), and owner placeholders still win. --- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- packages/client/ui-conversation/package.json | 1 + .../ui-conversation/src/client/apply.ts | 5 +- .../src/client/contract/slots.ts | 7 +- .../src/client/skeleton/InputBar.tsx | 14 ++- .../ui-conversation/tests/input-bar.spec.tsx | 19 ++- packages/client/ui-conversation/tsconfig.json | 3 + packages/client/ui-plan/README.i18n.yaml | 4 +- packages/client/ui-plan/README.md | 14 +-- packages/client/ui-plan/README.zh.md | 14 +-- .../src/client/PlanModeControl.module.css | 73 ++++------- .../ui-plan/src/client/PlanModeControl.tsx | 84 ++++++------- packages/client/ui-plan/src/client/index.ts | 35 +++--- .../ui-plan/tests/browser-plugin.spec.ts | 22 ++-- .../ui-plan/tests/plan-mode-control.spec.tsx | 117 ++++++++---------- pnpm-lock.yaml | 3 + .../verify-package-readme-model-experience.ts | 2 +- 19 files changed, 207 insertions(+), 218 deletions(-) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 4d5a458d7d..49e43861f3 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 51ddecf93240c2196483d3fb2bcfaca4104da31a -README.zh.md: d98cbcc69b875d2f426d9bdd9f2fa81874ec614a +README.md: 85cf040a48cf43b6ee6a8978ad7110ecdffb4051 +README.zh.md: 305258e2861fb17966050e295a5b980067a59a2d diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 51ddecf932..85cf040a48 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -16,7 +16,7 @@ The todo surfaces are two registrations over that shape, both plain registrant p Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks. -The composer bar declares session-scoped single seats for `'conversation.input.plan'` and `'conversation.input.model'`, plus list slots for overlay, dock, left, and right input extensions. InputBar renders the model seat immediately before its pending indicator and send/stop button. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The resident no-session shell uses `DisabledInputBar` and therefore dispatches no session-scoped control seats. +The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). The resident no-session shell uses `DisabledInputBar` and therefore dispatches no session-scoped control seats. `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). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index d98cbcc69b..305258e286 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -16,7 +16,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。 -输入栏为 `'conversation.input.plan'` 和 `'conversation.input.model'` 声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。InputBar 将模型 seat 渲染在 pending 指示器与发送/停止按钮之前。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。常驻无会话壳使用 `DisabledInputBar`,因此不会分发任何会话作用域的控件 seat。 +输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。常驻无会话壳使用 `DisabledInputBar`,因此不会分发任何会话作用域的控件 seat。 `src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 0b184dcbdf..2e37415bdc 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -49,6 +49,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-plan-mode": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 68df3a6da0..57138ec01e 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -129,8 +129,9 @@ export function apply(ctx: Context): void { // verbs ride this inject (package-internal — hub and bar are one plugin). slots.register({ name: 'conversation.composer.bar', - // The two named control seats in the bar's tool row (plan left, model - // right); empty until their owning plugins register (B ruling). + // The two named control seats in the bar's tool row (plan beside the + // access control, model right); empty until their owning plugins + // register (B ruling). children: { 'conversation.input.plan': { kind: 'single', scope: 'session' }, 'conversation.input.model': { kind: 'single', scope: 'session' }, diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index d390ffc007..3e7e665e66 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -83,9 +83,10 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { */ 'conversation.composer.bar': { kind: 'single'; scope: 'session'; owner: ComposerBarOwnerProps } /** - * The Plan-mode control seat in the composer tool row (left group). - * Declared by the composer-bar entry; empty until a plan plugin - * registers (B ruling: no placeholder fallback). + * The Plan-mode status seat in the composer tool row (left group, + * right of the access-mode control). Declared by the composer-bar + * entry; empty until a plan plugin registers (B ruling: no placeholder + * fallback). */ 'conversation.input.plan': { kind: 'single'; scope: 'session'; owner: InputControlOwnerProps } /** diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 26a65def6a..6c4859190d 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -10,6 +10,9 @@ import { useEffect, useRef, useState } from 'react' import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react' import clsx from 'clsx' import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +// Type-only: the `plan` projection key merge (the TodoDock posture — the +// composer reads a host-computed value; the domain owns the key). +import type {} from '@deepseek-ai/dsh-plan-mode/client' import type { ComposerBarProps } from '../contract/slots.ts' import { deriveDecorations } from '../input/decorations.ts' import css from './InputBar.module.css' @@ -28,7 +31,7 @@ const READONLY_OPTIONS: readonly { id: string; label: string }[] = [ ] export function InputBar({ - useSession, useInput, inputActions, keyboard, stop, renderSlot, useNotices, useLexicon, + useSession, useInput, inputActions, keyboard, stop, renderSlot, useNotices, useLexicon, useProjection, variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment', }: InputBarProps) { const input = useInput(s => s) @@ -37,6 +40,9 @@ export function InputBar({ const promptError = useSession(s => s.promptError) const running = useSession(s => s.running) const disabled = useSession(s => s.removed) + // Plan mode swaps the textarea placeholder (the projection is the folded + // host value; owner-prop placeholders — hero, session-unavailable — win). + const planActive = useProjection('plan', plan => plan !== undefined && (plan.pending ? !plan.active : plan.active)) // Prompt failures are ordinary failures (no create/attach transaction // exists anymore): the strip renders promptError, the draft stays in the // machine, and the user resubmits. @@ -334,7 +340,9 @@ export function InputBar({ disabled={locked} readOnly={machineBusy} data-phase={input.phase} - placeholder={placeholder ?? (disabled ? 'Session unavailable' : 'Message the agent')} + placeholder={placeholder ?? (disabled + ? 'Session unavailable' + : planActive ? 'describe your task to generate plan' : 'Message the agent')} rows={2} onChange={onChange} onKeyDown={onKeyDown} @@ -361,8 +369,8 @@ export function InputBar({
- {renderSlot('conversation.input.plan', { locked })} {accessSelect} + {renderSlot('conversation.input.plan', { locked })}
{leftItems} diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index f8b9fd3d67..12ef9841b8 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -30,6 +30,8 @@ function snapshotOf(overrides: Partial = {}): Conversation interface BenchOptions { planEntry?: React.ReactNode + /** The `plan` projection value the standard-kit useProjection serves. */ + plan?: { active: boolean; pending: boolean } modelEntry?: React.ReactNode /** Hot text-ref lexicon (injects a minimal slash stub exposing only lexicon()). */ lexicon?: ReadonlyMap<'/' | '@', readonly string[]> @@ -88,7 +90,8 @@ function bench(over?: BenchOptions) { items: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, })), - useProjection: (() => undefined), + useProjection: ((_key: string, selector?: (v: unknown) => unknown) => + (selector ?? (v => v))(over?.plan)), useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, @@ -230,6 +233,20 @@ describe('running and lock semantics (queue cut 1)', () => { const custom = bench({ placeholder: 'Custom placeholder' }) expect(custom.textarea.placeholder).toBe('Custom placeholder') }) + + it('the plan projection swaps the placeholder while its effective target is plan mode', () => { + const active = bench({ plan: { active: true, pending: false } }) + expect(active.textarea.placeholder).toBe('describe your task to generate plan') + // /plan just ran: pending entry already reads as the plan target. + const entering = bench({ plan: { active: false, pending: true } }) + expect(entering.textarea.placeholder).toBe('describe your task to generate plan') + // Pending exit: target is default again. + const leaving = bench({ plan: { active: true, pending: true } }) + expect(leaving.textarea.placeholder).toBe('Message the agent') + // Owner placeholder outranks the plan swap. + const custom = bench({ plan: { active: true, pending: false }, placeholder: 'Custom placeholder' }) + expect(custom.textarea.placeholder).toBe('Custom placeholder') + }) }) describe('machine pending lock', () => { diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json index 32ba48e8fe..c08f4b28f5 100644 --- a/packages/client/ui-conversation/tsconfig.json +++ b/packages/client/ui-conversation/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../session-projection/session-projection" }, + { + "path": "../../plan/plan-mode" + }, { "path": "../../todo/tool-todo" }, diff --git a/packages/client/ui-plan/README.i18n.yaml b/packages/client/ui-plan/README.i18n.yaml index 03915a0262..f7572c9bfa 100644 --- a/packages/client/ui-plan/README.i18n.yaml +++ b/packages/client/ui-plan/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-plan/README.md -README.md: a8472c82ed1a5d6e5ebe4ae46b030ee140aa07c1 -README.zh.md: 77db21f515fd80d06719aec54170d451fbb67ad6 +README.md: de43ce66d17498d31e05f8c64092ea0843103054 +README.zh.md: b4d2f4fd1a6d45f814d4a20195434f34d207e9c8 diff --git a/packages/client/ui-plan/README.md b/packages/client/ui-plan/README.md index a8472c82ed..de43ce66d1 100644 --- a/packages/client/ui-plan/README.md +++ b/packages/client/ui-plan/README.md @@ -2,24 +2,24 @@ English | [中文](README.zh.md) -Plan-mode composer control, a pure browser surface plugin. The browser half occupies the conversation-declared `conversation.input.plan` single seat with a pending-aware mode selector; the node half is an empty apply (the roster row). Plan behavior itself — the `/plan` command, the boundary-committed `plan/mode` state, the `plan` projection unit, and the policy section — is owned by [`@deepseek-ai/dsh-plan-mode`](../../plan/plan-mode/README.md), composed independently on the host roster. +Plan-mode status chip, a pure browser surface plugin. The browser half occupies the conversation-declared `conversation.input.plan` single seat (to the right of the access-mode control); the node half is an empty apply (the roster row). Plan behavior itself — the `/plan` command, the boundary-or-idle-committed `plan/mode` state, the `plan` projection unit, and the policy section — is owned by [`@deepseek-ai/dsh-plan-mode`](../../plan/plan-mode/README.md), composed independently on the host roster. -Reads ride the generic projection pair: the control renders the host-computed `plan` projection (`{ active, pending }`) through the standard-kit `useProjection`; an absent key is capability absence and hides the control, so a host without plan-mode (or a Draft with no session) shows no seat content. Writes ride the standard command channel: selecting a mode executes `/plan` or `/plan off` through `command.execute`, whose logged `command/run` immediately folds into a pending projection frame and whose request-boundary `plan/mode` commit resolves it — the control never holds client-side plan state, displays only host-confirmed values, and stays available while generation runs (switching never cancels a turn; the pending target applies at the next model-request boundary). +Plan mode is entered through the `/plan` command only; there is no UI control that turns it on. While the host-computed `plan` projection's effective target is plan mode (`pending ? !active : active` — a folded host value, not client optimism, so an arriving frame corrects the chip either way), the seat renders a read-only "Plan" chip whose hover × executes `/plan off` through `command.execute`; otherwise the seat stays empty — a host without plan-mode (or a Draft with no session) shows nothing. While plan mode is the effective target, the composer textarea's placeholder switches to "describe your task to generate plan" (rendered by the composer from the same projection; owner-supplied placeholders win). -The transparent native select mirrors keyboard focus onto the visible chip and carries a dynamic accessible description of the committed and pending modes. Admission failures (`matched: false`, business errors, transport faults) surface as an inline error without mutating the displayed mode. +The chip carries the accessible description "Plan mode on, press to turn off". Admission failures (`matched: false`, business errors, transport faults) surface as an inline error and the chip stays until the projection confirms the exit. The model exits plan mode through the stable `exit_plan_mode` tool; its plan review uses the composed Web question channel. ## Model Experience -Indirectly, through the `/plan` command lines the control dispatches: `@deepseek-ai/dsh-plan-mode` owns the model-visible policy section, the exit-tool schema, and the logged state those lines drive, while this package only renders the projection and sends what a user could equally type. +Indirectly, through the `/plan off` command line the chip dispatches: `@deepseek-ai/dsh-plan-mode` owns the model-visible policy section, the exit-tool schema, and the logged state that line drives, while this package only renders the projection and sends what a user could equally type. #### KV Cache effect -Entering or leaving plan mode changes the active `plan:policy` system-prompt section and therefore the request prefix; the control itself adds no prompt content. +Entering or leaving plan mode changes the active `plan:policy` system-prompt section and therefore the request prefix; the chip itself adds no prompt content. ## 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. -- **No Draft-time selection** — before a session exists there is no projection and the seat stays empty; plan mode is selected after the first prompt creates the session. +- **The chip belongs to the default composer** — a pending whole-composer interaction such as plan review temporarily replaces the InputBar and its chip. +- **No UI entry point** — plan mode is entered by typing `/plan`; a session with the capability but inactive mode shows no affordance in the tool row. diff --git a/packages/client/ui-plan/README.zh.md b/packages/client/ui-plan/README.zh.md index 77db21f515..b4d2f4fd1a 100644 --- a/packages/client/ui-plan/README.zh.md +++ b/packages/client/ui-plan/README.zh.md @@ -2,24 +2,24 @@ [English](README.md) | 中文 -Plan mode 编辑器控件,纯浏览器 surface 插件。浏览器侧以一个感知待生效态的模式选择器占据会话声明的 `conversation.input.plan` 单座;node 侧是空 apply(roster 行)。plan 行为本身——`/plan` 命令、边界提交的 `plan/mode` 状态、`plan` 投影单元与 policy 段——归 [`@deepseek-ai/dsh-plan-mode`](../../plan/plan-mode/README.md) 所有,由 host roster 独立组合。 +Plan mode 状态徽章,纯浏览器 surface 插件。浏览器侧占据会话声明的 `conversation.input.plan` 单座(位于 access 模式控件右侧);node 侧是空 apply(roster 行)。plan 行为本身——`/plan` 命令、边界或空闲即时提交的 `plan/mode` 状态、`plan` 投影单元与 policy 段——归 [`@deepseek-ai/dsh-plan-mode`](../../plan/plan-mode/README.md) 所有,由 host roster 独立组合。 -读取走通用投影对:控件经标准套件的 `useProjection` 渲染 host 计算的 `plan` 投影(`{ active, pending }`);key 缺席即能力缺席并隐藏控件,因此未组合 plan-mode 的 host(或尚无会话的 Draft)不展示座位内容。写入走标准命令通道:选择模式即经 `command.execute` 执行 `/plan` 或 `/plan off`,其入日志的 `command/run` 立即折叠为 pending 投影帧,请求边界的 `plan/mode` 提交将其兑现——控件不持有任何客户端侧 plan 状态,只显示 host 确认的值,且在生成期间保持可用(切换绝不取消轮次;待生效目标在下一次模型请求边界生效)。 +plan mode 只经 `/plan` 命令进入;UI 上没有打开它的控件。当 host 计算的 `plan` 投影有效目标为 plan mode 时(`pending ? !active : active`——折叠的 host 值而非客户端乐观态,帧到达即自动纠正),座位渲染一个只读 "Plan" chip,hover 出现的 × 经 `command.execute` 执行 `/plan off`;否则座位保持为空——未组合 plan-mode 的 host(或尚无会话的 Draft)不显示任何内容。plan mode 为有效目标期间,composer 文本框的 placeholder 切换为 "describe your task to generate plan"(由 composer 从同一投影渲染;owner 提供的 placeholder 优先)。 -透明的原生 select 把键盘焦点镜像到可见 chip 上,并携带区分已提交与待生效模式的动态无障碍描述。准入失败(`matched: false`、业务错误、传输故障)以内联错误呈现,不改变显示的模式。 +chip 携带无障碍描述 "Plan mode on, press to turn off"。准入失败(`matched: false`、业务错误、传输故障)以内联错误呈现,chip 保持显示直至投影确认退出。 模型通过稳定的 `exit_plan_mode` 工具退出 plan mode;其 plan 评审走已组合的 Web question 通道。 ## 模型体验 -间接地,通过控件派发的 `/plan` 命令行:`@deepseek-ai/dsh-plan-mode` 拥有这些命令行驱动的模型可见 policy 段、退出工具 schema 与已记录状态,本包只渲染投影并发送用户同样可以手敲的内容。 +间接地,通过 chip 派发的 `/plan off` 命令行:`@deepseek-ai/dsh-plan-mode` 拥有该命令行驱动的模型可见 policy 段、退出工具 schema 与已记录状态,本包只渲染投影并发送用户同样可以手敲的内容。 #### KV 缓存效应 -进入或离开 plan mode 会改变活跃的 `plan:policy` 系统提示词段,因此改变请求前缀;控件本身不添加任何提示词内容。 +进入或离开 plan mode 会改变活跃的 `plan:policy` 系统提示词段,因此改变请求前缀;chip 本身不添加任何提示词内容。 ## 已知局限与延后工作 - **Plan mode 是引导而非执行沙箱**——需要强制只读规划的部署必须组合独立的沙箱与审批策略。 -- **控件属于默认编辑器**——待处理的整编辑器交互(如 plan 评审)会临时取代 InputBar 及其模式控件。 -- **无 Draft 期选择**——会话存在之前没有投影,座位保持为空;plan mode 在首个 prompt 创建会话后选择。 +- **chip 属于默认编辑器**——待处理的整编辑器交互(如 plan 评审)会临时取代 InputBar 及其 chip。 +- **无 UI 进入点**——plan mode 靠敲 `/plan` 进入;有能力但未激活的会话在工具行不显示任何入口。 diff --git a/packages/client/ui-plan/src/client/PlanModeControl.module.css b/packages/client/ui-plan/src/client/PlanModeControl.module.css index 52bf367e3e..4bf42bd2a1 100644 --- a/packages/client/ui-plan/src/client/PlanModeControl.module.css +++ b/packages/client/ui-plan/src/client/PlanModeControl.module.css @@ -1,5 +1,5 @@ -/* Quiet composer chip: an invisible native select owns interaction and - accessibility while the visible layer reports committed/pending state. */ +/* Read-only plan status badge: quiet chip; the × affordance appears on + hover/focus and the whole chip is the /plan off button. */ .wrap { display: inline-flex; @@ -7,65 +7,46 @@ gap: 6px; } -.root { - position: relative; - display: inline-flex; - align-items: center; -} - .chip { display: inline-flex; align-items: center; gap: 4px; padding: 6px 8px; + border: none; border-radius: 8px; + background: transparent; 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); -} - -.root:focus-within .chip { - outline: 2px solid var(--dsw-alias-brand-primary); - outline-offset: 2px; -} - -.chevron { - color: var(--dsw-alias-label-caption); -} - -.description { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} - -.select { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - opacity: 0; - border: none; cursor: pointer; } -.select:disabled { +.chip:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); +} + +.chip:focus-visible { + outline: 2px solid var(--dsw-alias-label-secondary); + outline-offset: 2px; +} + +.chip:disabled { + opacity: 0.6; cursor: default; } -.root:has(.select:disabled) .chip { - opacity: 0.5; +.close { + display: inline-flex; + align-items: center; + color: var(--dsw-alias-label-caption); + opacity: 0; + transition: opacity 0.12s ease; +} + +.chip:hover .close, +.chip:focus-visible .close { + opacity: 1; + color: var(--dsw-alias-label-secondary); } .error { diff --git a/packages/client/ui-plan/src/client/PlanModeControl.tsx b/packages/client/ui-plan/src/client/PlanModeControl.tsx index 6338255415..711d17e675 100644 --- a/packages/client/ui-plan/src/client/PlanModeControl.tsx +++ b/packages/client/ui-plan/src/client/PlanModeControl.tsx @@ -1,27 +1,27 @@ -import { useEffect, useId, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: pulls the ui-conversation SlotMap merge (the input.plan seat and // its {locked} owner share). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { PlanModeControlInjected } from './index.ts' +import type { PlanChipInjected } from './index.ts' import css from './PlanModeControl.module.css' /** Full plan-seat component props: runtime share (standard kit + locked owner prop) & injected share. */ -export type PlanModeControlProps = - PropsRuntime<'conversation.input.plan'> & InjectFace +export type PlanChipProps = + PropsRuntime<'conversation.input.plan'> & InjectFace -const labels = { - default: '默认', - plan: '计划', -} as const - -/** Composer control over the host-computed `plan` projection. */ -export function PlanModeControl({ useProjection, locked, setPlanMode }: PlanModeControlProps) { +/** + * Read-only status badge over the host-computed `plan` projection. Plan mode + * is entered through the /plan command only; the chip appears while the + * effective target is plan mode and its hover × executes /plan off. The + * displayed state follows the target (`pending ? !active : active`) — a + * folded host value, not client optimism, so an arriving frame corrects it. + */ +export function PlanChip({ useProjection, locked, exitPlanMode }: PlanChipProps) { const plan = useProjection('plan') - const [switching, setSwitching] = useState(false) + const [leaving, setLeaving] = useState(false) const [error, setError] = useState(null) const aliveRef = useRef(true) - const descriptionId = useId() useEffect(() => { aliveRef.current = true @@ -30,57 +30,45 @@ export function PlanModeControl({ useProjection, locked, setPlanMode }: PlanMode } }, []) - // Capability absence: the host composed no plan-mode plugin (or no - // baseline has arrived yet) — the seat stays empty. + // Absent capability (no plan-mode host plugin / no session yet) or the + // default mode: no seat content. if (plan === undefined) return null - const target = plan.pending ? !plan.active : plan.active - const value = target ? 'plan' : 'default' - const currentLabel = labels[plan.active ? 'plan' : 'default'] - const targetLabel = labels[value] - const label = `${targetLabel}${plan.pending ? ' · 待生效' : ''}` - const title = plan.pending - ? `当前为${currentLabel}模式;${targetLabel}模式将在下一次模型请求时生效` - : `当前为${currentLabel}模式` + if (!target) return null - const select = (active: boolean): void => { - if (active === target || switching) return - setSwitching(true) + const off = (): void => { + if (leaving || locked) return + setLeaving(true) setError(null) - void setPlanMode(active).then((failure) => { + void exitPlanMode().then((failure) => { if (!aliveRef.current) return - setSwitching(false) + setLeaving(false) setError(failure) }, (reason: unknown) => { if (!aliveRef.current) return - setSwitching(false) + setLeaving(false) setError(reason instanceof Error ? reason.message : String(reason)) }) } return ( - - {error !== null && 模式切换失败} + + {error !== null && 退出 plan mode 失败} ) } diff --git a/packages/client/ui-plan/src/client/index.ts b/packages/client/ui-plan/src/client/index.ts index d799d33632..2780d66a09 100644 --- a/packages/client/ui-plan/src/client/index.ts +++ b/packages/client/ui-plan/src/client/index.ts @@ -1,12 +1,11 @@ /** * Plan control plugin, browser half: occupies the composer's named - * `conversation.input.plan` seat with a pending-aware mode selector. Reads - * ride the generic projection pair — the control renders the `plan` - * projection through the standard-kit `useProjection` (an absent key is - * capability absence and hides the control); writes ride the standard - * command channel — selecting a mode executes `/plan` / `/plan off` through - * `command.execute`, whose logged lifecycle plus the boundary `plan/mode` - * commit come back as projection frames. Zero client-side plan state. + * `conversation.input.plan` seat with a read-only status chip. Plan mode is + * entered through the /plan command only; while the projection's effective + * target is plan mode the chip renders (hover × executes /plan off through + * `command.execute`), otherwise the seat stays empty. Reads ride the generic + * projection pair through the standard-kit `useProjection` (an absent key is + * capability absence); zero client-side plan state. */ import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' @@ -14,16 +13,15 @@ import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/c import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' // Type-only: pulls the `plan` SessionProjectionMap merge for useProjection. import type {} from '@deepseek-ai/dsh-plan-mode/client' -import { PlanModeControl } from './PlanModeControl.tsx' +import { PlanChip } from './PlanModeControl.tsx' /** Injected business face of the composer plan seat. */ -export interface PlanModeControlInjected { +export interface PlanChipInjected { /** - * Select the target mode by executing the corresponding /plan line. - * @param active - whether plan mode should be active from the next boundary. + * Leave plan mode by executing /plan off. * @returns null on admitted execution; a user-visible failure line otherwise. */ - setPlanMode: (active: boolean) => Promise + exitPlanMode: () => Promise } /** @@ -33,21 +31,20 @@ export interface PlanModeControlInjected { export const inject = ['slots', 'connection', 'conversation'] /** - * Client plugin body: register the plan seat occupant over the command channel. + * Client plugin body: register the plan chip over the command channel. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.slots.register({ name: 'conversation.input.plan', - inject: (sessionId: SessionId): PlanModeControlInjected => ({ - setPlanMode: async (active) => { + inject: (sessionId: SessionId): PlanChipInjected => ({ + exitPlanMode: async () => { const connection = ctx.get('connection') as ConnectionHandle - const line = active ? '/plan' : '/plan off' - const { result } = await connection.api.commands.execute({ sessionId, line }) + const { result } = await connection.api.commands.execute({ sessionId, line: '/plan off' }) if (!result.ok) return `${result.error.message}(${result.error.code})` - if (!result.value.matched) return `未知命令:${line}` + if (!result.value.matched) return '未知命令:/plan off' return null }, }), - }, PlanModeControl), 'ui-plan: composer plan seat registration') + }, PlanChip), 'ui-plan: composer plan chip registration') } diff --git a/packages/client/ui-plan/tests/browser-plugin.spec.ts b/packages/client/ui-plan/tests/browser-plugin.spec.ts index 1a6b85a586..865ff67042 100644 --- a/packages/client/ui-plan/tests/browser-plugin.spec.ts +++ b/packages/client/ui-plan/tests/browser-plugin.spec.ts @@ -1,7 +1,7 @@ /** * ui-plan browser half on a real SlotsService: the plugin occupies the - * conversation-declared `conversation.input.plan` single seat; the injected - * face maps mode selections onto /plan command lines and folds admission + * conversation-declared `conversation.input.plan` single seat with the plan + * status chip; the injected face executes /plan off and folds admission * outcomes into null (admitted) or a user-visible failure line; teardown * empties the seat (HMR safety). */ @@ -9,8 +9,8 @@ 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 { PlanChip } from '../src/client/PlanModeControl.tsx' +import type { PlanChipInjected } from '../src/client/index.ts' import { apply, inject } from '../src/client/index.ts' import { apply as nodeApply } from '../src/index.ts' @@ -49,30 +49,28 @@ describe('ui-plan browser apply', () => { .rejects.toThrow(/slot "conversation.input.plan" is not declared/) }) - it('registers the control, maps selections to /plan lines, and unregisters on teardown', async () => { + it('registers the chip, executes /plan off, 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.input.plan')[0]! - expect(entry.component).toBe(PlanModeControl) - const injected = (entry.inject as unknown as (id: SessionId) => PlanModeControlInjected)(SID) + expect(entry.component).toBe(PlanChip) + const injected = (entry.inject as unknown as (id: SessionId) => PlanChipInjected)(SID) - await expect(injected.setPlanMode(true)).resolves.toBeNull() - expect(b.execute).toHaveBeenLastCalledWith({ sessionId: SID, line: '/plan' }) - await expect(injected.setPlanMode(false)).resolves.toBeNull() + await expect(injected.exitPlanMode()).resolves.toBeNull() expect(b.execute).toHaveBeenLastCalledWith({ sessionId: SID, line: '/plan off' }) // Business failure folds to the composer-visible line. b.execute.mockResolvedValueOnce({ result: { ok: false as const, error: { code: 'session-not-found', message: 'gone', details: {} } }, } as never) - await expect(injected.setPlanMode(true)).resolves.toBe('gone(session-not-found)') + await expect(injected.exitPlanMode()).resolves.toBe('gone(session-not-found)') // Unmatched admission (plan-mode not composed host-side) is also a failure line. b.execute.mockResolvedValueOnce({ result: { ok: true as const, value: { matched: false as const } }, } as never) - await expect(injected.setPlanMode(true)).resolves.toBe('未知命令:/plan') + await expect(injected.exitPlanMode()).resolves.toBe('未知命令:/plan off') await fiber.dispose() expect(b.slots.entries('conversation.input.plan')).toHaveLength(0) diff --git a/packages/client/ui-plan/tests/plan-mode-control.spec.tsx b/packages/client/ui-plan/tests/plan-mode-control.spec.tsx index bc41f8a775..44c17e5837 100644 --- a/packages/client/ui-plan/tests/plan-mode-control.spec.tsx +++ b/packages/client/ui-plan/tests/plan-mode-control.spec.tsx @@ -1,119 +1,110 @@ // @vitest-environment jsdom /** - * PlanModeControl over the `plan` projection: an absent key (capability - * absence) hides the control; {active, pending} renders committed and - * pending-target labels; selection maps from the effective target and - * surfaces failures without mutating the host-confirmed state. + * PlanChip over the `plan` projection: nothing renders while the capability + * is absent or the effective target is the default mode; the chip renders + * while the target is plan mode (pending follows the target — /plan shows it + * immediately, /plan off hides it immediately); the chip button executes + * /plan off and surfaces failures without hiding until the projection says so. */ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import type { PlanProjection } from '@deepseek-ai/dsh-plan-mode/client' -import { PlanModeControl, type PlanModeControlProps } from '../src/client/PlanModeControl.tsx' +import { PlanChip, type PlanChipProps } from '../src/client/PlanModeControl.tsx' afterEach(cleanup) function setup( plan: PlanProjection | undefined, - setPlanMode = vi.fn(() => Promise.resolve(null)), + exitPlanMode = vi.fn(() => Promise.resolve(null)), locked = false, ) { const store = createSnapshotStore<{ value: PlanProjection | undefined }>({ value: plan }) const useProjection = (_key: string, selector?: (v: unknown) => unknown) => bindSnapshotSelector(store)(s => (selector ?? (v => v))(s.value)) - const props = { useProjection, locked, setPlanMode } as unknown as PlanModeControlProps - const view = render() - return { store, setPlanMode, view } + const props = { useProjection, locked, exitPlanMode } as unknown as PlanChipProps + const view = render() + return { store, exitPlanMode, view } } -describe('PlanModeControl', () => { - it('hides an absent capability and renders committed modes', () => { +const chip = () => screen.getByRole('button', { name: 'Plan mode on, press to turn off' }) + +describe('PlanChip', () => { + it('renders nothing for absent capability or the default mode', () => { const absent = setup(undefined) expect(absent.view.container.innerHTML).toBe('') cleanup() - setup({ active: false, pending: false }) - expect(screen.getByTitle('当前为默认模式')).toBeTruthy() - const select = screen.getByRole('combobox', { name: '协作模式' }) - expect(select.value).toBe('default') - expect(document.getElementById(select.getAttribute('aria-describedby') ?? '')?.textContent) - .toBe('当前为默认模式') - }) - - it('renders the pending target as the opposite of the committed state', () => { - setup({ active: false, pending: true }) - expect(screen.getByText('计划 · 待生效')).toBeTruthy() - const planSelect = screen.getByRole('combobox') - expect(document.getElementById(planSelect.getAttribute('aria-describedby') ?? '')?.textContent) - .toBe('当前为默认模式;计划模式将在下一次模型请求时生效') + const inactive = setup({ active: false, pending: false }) + expect(inactive.view.container.innerHTML).toBe('') cleanup() - setup({ active: true, pending: true }) - expect(screen.getByText('默认 · 待生效')).toBeTruthy() - const defaultSelect = screen.getByRole('combobox') - expect(defaultSelect.value).toBe('default') - expect(document.getElementById(defaultSelect.getAttribute('aria-describedby') ?? '')?.textContent) - .toBe('当前为计划模式;默认模式将在下一次模型请求时生效') + // Active with a pending exit: the target is default — chip already gone. + const leaving = setup({ active: true, pending: true }) + expect(leaving.view.container.innerHTML).toBe('') }) - it('switches from the effective target, disables during its own call, and follows the pushed projection', async () => { - let resolve!: (value: string | null) => void - const setPlanMode = vi.fn(() => new Promise((done) => { resolve = done })) - const { store } = setup({ active: false, pending: false }, setPlanMode) - const select = screen.getByRole('combobox', { name: '协作模式' }) - expect(select.disabled).toBe(false) - fireEvent.change(select, { target: { value: 'plan' } }) - expect(setPlanMode).toHaveBeenCalledWith(true) - expect(select.disabled).toBe(true) + it('renders while the effective target is plan mode, including the pending entry window', () => { + setup({ active: true, pending: false }) + expect(chip()).toBeTruthy() + cleanup() + // /plan just ran (command/run folded, plan/mode not yet): target is plan. + setup({ active: false, pending: true }) + expect(chip()).toBeTruthy() + }) - // The projection frame lands (command/run folded host-side). - store.set({ value: { active: false, pending: true } }) + it('the chip executes /plan off once and follows the projection down', async () => { + let resolve!: (value: string | null) => void + const exitPlanMode = vi.fn(() => new Promise((done) => { resolve = done })) + const { store } = setup({ active: true, pending: false }, exitPlanMode) + fireEvent.click(chip()) + expect(exitPlanMode).toHaveBeenCalledTimes(1) + // Busy while its own call is in flight. + fireEvent.click(chip()) + expect(exitPlanMode).toHaveBeenCalledTimes(1) resolve(null) + // The off command's run record folds: target flips, the chip unmounts. + store.set({ value: { active: true, pending: true } }) await waitFor(() => { - expect(screen.getByRole('combobox').disabled).toBe(false) + expect(screen.queryByRole('button', { name: 'Plan mode on, press to turn off' })).toBeNull() }) - expect(screen.getByText('计划 · 待生效')).toBeTruthy() - // Re-selecting the effective target is a no-op. - fireEvent.change(screen.getByRole('combobox'), { target: { value: 'plan' } }) - expect(setPlanMode).toHaveBeenCalledTimes(1) }) it('disables under the locked owner prop', () => { - setup({ active: false, pending: false }, vi.fn(), true) - expect(screen.getByRole('combobox').disabled).toBe(true) + setup({ active: true, pending: false }, vi.fn(), true) + expect((chip() as HTMLButtonElement).disabled).toBe(true) }) - it('surfaces admission and transport failures without changing the confirmed mode', async () => { - const setPlanMode = vi.fn() + it('surfaces admission and transport failures while staying visible', async () => { + const exitPlanMode = vi.fn() .mockResolvedValueOnce('host said no') .mockRejectedValueOnce(new Error('network down')) .mockRejectedValueOnce('socket closed') - setup({ active: false, pending: false }, setPlanMode) - fireEvent.change(screen.getByRole('combobox'), { target: { value: 'plan' } }) - expect((await screen.findByText('模式切换失败')).getAttribute('title')).toBe('host said no') - expect(screen.getByTitle('当前为默认模式')).toBeTruthy() + setup({ active: true, pending: false }, exitPlanMode) + fireEvent.click(chip()) + expect((await screen.findByText('退出 plan mode 失败')).getAttribute('title')).toBe('host said no') + expect(chip()).toBeTruthy() - fireEvent.change(screen.getByRole('combobox'), { target: { value: 'plan' } }) + fireEvent.click(chip()) expect(await screen.findByTitle('network down')).toBeTruthy() - expect(screen.getByRole('combobox').disabled).toBe(false) - fireEvent.change(screen.getByRole('combobox'), { target: { value: 'plan' } }) + fireEvent.click(chip()) 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, pending: false }, + { active: true, pending: false }, vi.fn(() => new Promise((done) => { resolve = done })), ) - fireEvent.change(screen.getByRole('combobox'), { target: { value: 'plan' } }) + fireEvent.click(chip()) successful.view.unmount() expect(() => { resolve(null) }).not.toThrow() let reject!: (reason: unknown) => void - const setPlanMode = vi.fn(() => new Promise((_done, fail) => { reject = fail })) - const { view } = setup({ active: false, pending: false }, setPlanMode) - fireEvent.change(screen.getByRole('combobox'), { target: { value: 'plan' } }) + const exitPlanMode = vi.fn(() => new Promise((_done, fail) => { reject = fail })) + const { view } = setup({ active: true, pending: false }, exitPlanMode) + fireEvent.click(chip()) view.unmount() expect(() => { reject(new Error('late')) }).not.toThrow() }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ea949dfdd..2e9a675a8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -985,6 +985,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-plan-mode': + specifier: workspace:^ + version: link:../../plan/plan-mode '@deepseek-ai/dsh-session-projection': specifier: workspace:^ version: link:../../session-projection/session-projection diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index faf7ad13ea..d9191bfe55 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -58,7 +58,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-slash': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-command': { kind: 'indirect', reason: 'The dispatch paths trigger the host command.execute RPC; each command handler\'s host package owns any model-visible effect.' }, 'packages/client/ui-model': { kind: 'indirect', reason: 'Selection routes session.selectModel; the host snapshots the target at the next prompt-assembly boundary and owns the model-visible effect.' }, - 'packages/client/ui-plan': { kind: 'indirect', reason: 'The control dispatches /plan command lines; dsh-plan-mode owns the model-visible policy, exit tool, and logged state.' }, + 'packages/client/ui-plan': { kind: 'indirect', reason: 'The chip dispatches /plan off; dsh-plan-mode owns the model-visible policy, exit tool, and logged state.' }, '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-workspace': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },