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({