Selaa lähdekoodia

fix(web): show plan status only while active

kingwl 2 kuukautta sitten
vanhempi
sitoutus
1986de9ac1
30 muutettua tiedostoa jossa 212 lisäystä ja 157 poistoa
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml
  2. 2 1
      .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md
  3. 2 1
      .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md
  4. 56 1
      apps/web/tests/lifecycle-chrome.e2e.ts
  5. 0 1
      apps/web/tests/snapshots/code-mode-round/ui.expected.md
  6. 0 1
      apps/web/tests/snapshots/cordis-tool-round/ui.expected.md
  7. 0 1
      apps/web/tests/snapshots/fresh-round-trip/ui.expected.md
  8. 0 1
      apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md
  9. 39 0
      apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md
  10. 0 1
      apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md
  11. 0 1
      apps/web/tests/snapshots/live-interactions/cancel.expected.md
  12. 0 1
      apps/web/tests/snapshots/live-interactions/error-auth.expected.md
  13. 0 1
      apps/web/tests/snapshots/live-interactions/retry.expected.md
  14. 0 1
      apps/web/tests/snapshots/message-actions/ui.expected.md
  15. 0 1
      apps/web/tests/snapshots/question-composer/answered.expected.md
  16. 0 1
      apps/web/tests/snapshots/queue-actions/editing.expected.md
  17. 0 1
      apps/web/tests/snapshots/queue-actions/ui.expected.md
  18. 0 1
      apps/web/tests/snapshots/seeded-history/ui.expected.md
  19. 0 1
      apps/web/tests/snapshots/steering/settled.expected.md
  20. 2 2
      packages/client/ui-plan/README.i18n.yaml
  21. 2 2
      packages/client/ui-plan/README.md
  22. 2 2
      packages/client/ui-plan/README.zh.md
  23. 2 0
      packages/client/ui-plan/package.json
  24. 17 17
      packages/client/ui-plan/src/client/PlanModeControl.module.css
  25. 24 29
      packages/client/ui-plan/src/client/PlanModeControl.tsx
  26. 11 13
      packages/client/ui-plan/src/client/index.ts
  27. 8 10
      packages/client/ui-plan/tests/browser-plugin.spec.ts
  28. 37 63
      packages/client/ui-plan/tests/plan-mode-control.spec.tsx
  29. 3 0
      packages/client/ui-plan/tsconfig.json
  30. 3 0
      pnpm-lock.yaml

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.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 .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md
-2026-07-25-web-input-machine-and-slash-pipeline.md: 2793e9045fe5a3c82f52c65503dd4a8cdf6a0596
-2026-07-25-web-input-machine-and-slash-pipeline.zh.md: e3a35c4e55525fedd835eace973f114bd15da37b
+2026-07-25-web-input-machine-and-slash-pipeline.md: c3deadb34d3a633525dde701c92bcc98c05e5d6e
+2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 7a6988423dcdffebb0a28735146439c8ade0a862

+ 2 - 1
.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md

@@ -101,7 +101,7 @@ skill/@subagent references skip the placeholder + occurrence identity chain —
 - `conversation.input.dock` — the stacked strip above the input (QueueDock's read-only queue list lands here), ordered by `order`.
 - `conversation.composer.dock` — the stats band on the composer's top edge.
 - `conversation.input.left` / `conversation.input.right` — the tool-row left and right regions.
-- `conversation.input.plan` / `conversation.input.model` (single) — the tool row's two named control seats; the bar passes only `locked` (owner props), each stays empty until its owning plugin registers, no placeholder fallback.
+- `conversation.input.plan` / `conversation.input.model` (single) — the tool row's two named control seats; the bar passes only `locked` (owner props), each stays empty until its owning plugin registers, no placeholder fallback. The plan seat stays empty while inactive because the shared Command source owns entry; an effective plan target renders the warn-state `Plan ×` status button, whose only action is `/plan off`.
 - `conversation.hero.workspace` (root scope) — the Workspace picker shared by the no-session and blank Hero; a pick reuses or creates the target blank session through `connectWorkspace`, moving the draft where necessary before switching current.
 
 ### Testing discipline
@@ -122,6 +122,7 @@ The state machine's entire behavior is covered by pure-JS unit tests (event sequ
 | Space adjudication also claiming execute-kind commands | The misfire defense: after a space the whole line is an ordinary prompt; irreversible side effects keep explicit entry points only |
 | A generic tokenPattern decoration mechanism | Structured occurrence records replace pattern scanning |
 | A placeholder select resident in the tool row | Named seats stay empty until registration; a placeholder clashing with the real implementation is two sources of truth |
+| An always-visible Plan on/off toggle | The shared Command source already owns entry; a second entry point turns a status seat into redundant mode chrome |
 | A second plus-menu component/controller, or an Add/File group above Command | It would duplicate async candidates, keyboard highlight, focus retention, and pick state; the plus control is only a source-filtered launcher for the existing MenuView, and this scope has no file capability |
 | All references through U+FFFC chips (the pre-Decision-21 line) | Plain text + derived decoration carries zero identity state; the literal text IS the model projection, sparing undo/clipboard any special cases; the chip chain is kept for scenarios needing indivisible atomicity |
 

+ 2 - 1
.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md

@@ -101,7 +101,7 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——pick 直接把
 - `conversation.input.dock`——输入上方堆叠条(QueueDock 的队列只读列表落此),order 定序。
 - `conversation.composer.dock`——composer 上沿统计带。
 - `conversation.input.left` / `conversation.input.right`——工具行左右区。
-- `conversation.input.plan` / `conversation.input.model`(single)——工具行两具名控制位;bar 只传 `locked`(owner props),空到 owning 插件注册为止,无占位 fallback。
+- `conversation.input.plan` / `conversation.input.model`(single)——工具行两具名控制位;bar 只传 `locked`(owner props),空到 owning 插件注册为止,无占位 fallback。plan seat 未激活时保持为空,因为入口归共享 Command source 所有;有效 plan 目标会渲染 warn 状态的 `Plan ×` 状态按钮,其唯一动作是 `/plan off`。
 - `conversation.hero.workspace`(root scope)——无 session / blank Hero 共用的 Workspace picker;pick 经 `connectWorkspace` 复用或创建目标 blank session,必要时搬运 draft 后切 current。
 
 ### 测试纪律
@@ -122,6 +122,7 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——pick 直接把
 | 空格裁决也认领即执行型命令 | 误触发防线:空格后整行是普通 prompt;不可逆副作用只留显式入口 |
 | 通用 tokenPattern 装饰机制 | 结构化 occurrence 记录取代模式扫描 |
 | 占位 select 常驻工具行 | 具名坑位空到注册为止;占位件与真实现冲突时是双真相源 |
+| 始终可见的 Plan 开/关切换 | 入口已归共享 Command source 所有;第二个入口会把状态 seat 变成冗余的 mode chrome |
 | 第二套加号菜单组件/controller,或在 Command 上方增加 Add/File 分组 | 这会重复异步候选、键盘高亮、焦点保留与 pick 状态;加号控件只是既有 MenuView 按 source 过滤的 launcher,且此 scope 没有文件能力 |
 | 引用一律走 U+FFFC chip(决策 21 前旧线) | 纯文本 + 派生装饰零身份状态;原文即模型投影,undo/剪贴板免特判;chip 链保留给需要不可分原子性的场景 |
 

+ 56 - 1
apps/web/tests/lifecycle-chrome.e2e.ts

@@ -26,6 +26,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/lifecycle-chrome', impor
 const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
 const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md')
 const COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu.expected.md')
+const PLAN_ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'plan-active.expected.md')
 // Post-reload golden: the same settled conversation rebuilt purely from
 // persistence + history — byte-equal rendering is exactly the recovery claim.
 const RELOADED_EXPECTED = join(SNAPSHOT_DIR, 'reloaded.expected.md')
@@ -85,6 +86,60 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
     await expect.poll(() => menu.count()).toBe(0)
   })
 
+  it.skipIf(MODE === 'record')('shows active Plan as the warn-state status action', async () => {
+    const activeScaffold = await launchWebScaffold()
+    const activePage = await newEnglishPage(browser)
+    const activeTripwire = watchConsole(activePage)
+    try {
+      await activePage.goto(activeScaffold.baseUrl, { waitUntil: 'load' })
+      await activePage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+      await connectFreshWorkspace(activePage)
+      const input = activePage.locator('textarea').first()
+      await activePage.getByRole('button', { name: 'Commands' }).click()
+      const menu = activePage.getByRole('listbox', { name: 'Trigger suggestions' })
+      await menu.waitFor({ timeout: 10_000 })
+      await menu.getByRole('option', { name: 'plan Enter or leave plan mode' }).click()
+      await expect.poll(() => input.inputValue()).toBe('/plan ')
+      await input.press('Enter')
+      const planButton = activePage.getByRole('button', { name: 'Plan mode on, press to turn off' })
+      await planButton.waitFor({ timeout: 10_000 })
+      const planSnapshot = await captureStableAria(activePage, '[class*="frame"]', activeScaffold.workspaceCwd)
+      await compareOrRefreshGolden(PLAN_ACTIVE_EXPECTED, planSnapshot, MODE)
+      const planStyle = await planButton.evaluate((element) => {
+        const probe = document.createElement('span')
+        probe.style.color = 'var(--dsw-alias-state-warn-label)'
+        probe.style.backgroundColor = 'var(--dsw-alias-state-warn-tertiary)'
+        document.body.append(probe)
+        const actual = getComputedStyle(element)
+        const reference = getComputedStyle(probe)
+        const result = {
+          color: actual.color,
+          backgroundColor: actual.backgroundColor,
+          borderRadius: actual.borderRadius,
+          fontSize: actual.fontSize,
+          referenceColor: reference.color,
+          referenceBackgroundColor: reference.backgroundColor,
+        }
+        probe.remove()
+        return result
+      })
+      expect(planStyle.color).toBe(planStyle.referenceColor)
+      expect(planStyle.backgroundColor).toBe(planStyle.referenceBackgroundColor)
+      expect(planStyle.borderRadius).toBe('999px')
+      expect(planStyle.fontSize).toBe('13px')
+      await planButton.click()
+      await expect.poll(() => planButton.count()).toBe(0)
+      expect(activeTripwire.pageErrors).toEqual([])
+      expect(activeTripwire.warnings).toEqual([])
+    } catch (error) {
+      await saveFailureShot(activePage, 'web-e2e-plan-active').catch(() => undefined)
+      throw error
+    } finally {
+      await activePage.close()
+      await activeScaffold.close()
+    }
+  })
+
   it('sends the first prompt from the empty-state hero (all modes)', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-send'))
     if (MODE !== 'record') {
@@ -182,7 +237,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
   it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
     expect(tripwire.warnings).toEqual([])
     await assertFixtureInventory(SNAPSHOT_DIR, [
-      'session.jsonl', 'command-menu.expected.md', 'hero.expected.md', 'reloaded.expected.md',
+      'session.jsonl', 'command-menu.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md',
     ])
   })
 })

+ 0 - 1
apps/web/tests/snapshots/code-mode-round/ui.expected.md

@@ -36,7 +36,6 @@
 - button "Commands":
   - img
 - 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img

+ 0 - 1
apps/web/tests/snapshots/cordis-tool-round/ui.expected.md

@@ -50,7 +50,6 @@
 - button "Commands":
   - img
 - 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img

+ 0 - 1
apps/web/tests/snapshots/fresh-round-trip/ui.expected.md

@@ -33,7 +33,6 @@
 - button "Commands":
   - img
 - 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img

+ 0 - 1
apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md

@@ -29,7 +29,6 @@
 - button "Commands":
   - img
 - 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img

+ 39 - 0
apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md

@@ -0,0 +1,39 @@
+- button "New session"
+- button "Collapse sidebar":
+  - img
+- button "New session":
+  - img
+  - text: New Session
+- text: Workspaces
+- button "Group by":
+  - img
+- button "Create workspace":
+  - img
+- button "Search sessions":
+  - img
+- textbox "Search name, keywords..."
+- tree "Sessions":
+  - treeitem "workspace 1 session" [expanded]:
+    - img
+    - text: workspace 1 session
+  - treeitem "New Session now" [selected]
+- button "Settings":
+  - img
+  - text: Settings
+- text: Let's start building
+- button "Choose workspace":
+  - img
+  - text: workspace
+  - img
+- textbox "Describe what you want to build"
+- button "Commands":
+  - img
+- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- button "Plan mode on, press to turn off": Plan
+- button "Select model, current deepseek-v4-flash":
+  - text: deepseek-v4-flash
+  - img
+- button "Send message" [disabled]
+- text: 详情
+- button "关闭详情"
+- text: 点击消息流中的工具行查看详情

+ 0 - 1
apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md

@@ -25,7 +25,6 @@
 - button "Commands":
   - img
 - 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img

+ 0 - 1
apps/web/tests/snapshots/live-interactions/cancel.expected.md

@@ -22,7 +22,6 @@
 - button "Commands":
   - img
 - 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img

+ 0 - 1
apps/web/tests/snapshots/live-interactions/error-auth.expected.md

@@ -15,7 +15,6 @@
 - button "Commands":
   - img
 - 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img

+ 0 - 1
apps/web/tests/snapshots/live-interactions/retry.expected.md

@@ -25,7 +25,6 @@
 - button "Commands":
   - img
 - 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img

+ 0 - 1
apps/web/tests/snapshots/message-actions/ui.expected.md

@@ -36,7 +36,6 @@
 - button "Commands":
   - img
 - 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
 - button "Select model, current deepseek-v4-flash":
   - text: deepseek-v4-flash
   - img

+ 0 - 1
apps/web/tests/snapshots/question-composer/answered.expected.md

@@ -33,7 +33,6 @@
 - button "Commands":
   - img
 - 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img

+ 0 - 1
apps/web/tests/snapshots/queue-actions/editing.expected.md

@@ -29,7 +29,6 @@
 - button "Commands":
   - img
 - 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img

+ 0 - 1
apps/web/tests/snapshots/queue-actions/ui.expected.md

@@ -23,7 +23,6 @@
 - button "Commands":
   - img
 - 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img

+ 0 - 1
apps/web/tests/snapshots/seeded-history/ui.expected.md

@@ -39,7 +39,6 @@
 - button "Commands":
   - img
 - 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
 - button "Select model, current deepseek-v4-flash":
   - text: deepseek-v4-flash
   - img

+ 0 - 1
apps/web/tests/snapshots/steering/settled.expected.md

@@ -33,7 +33,6 @@
 - button "Commands":
   - img
 - 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img

+ 2 - 2
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: 1d22c057b439ff337bf9daadcdba96dd4cca4540
-README.zh.md: 183b8ef7776b60c1f0afa630e04627a474d40391
+README.md: 2f83dd738e11ada5b24a55d1eec97e154ffd2aea
+README.zh.md: a026390191179f6492a55ee931a3406d8e1953e8

+ 2 - 2
packages/client/ui-plan/README.md

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
 
 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.
 
-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 the plan-task hint — "describe your task to generate plan", localized through ui-conversation's `command.hint` locale namespace and shared verbatim with the claimed `/plan` command hint (rendered by the composer from the same projection; owner-supplied placeholders win).
+Plan mode is entered through the `/plan` command path: users can choose Plan from the composer's `+` Command menu or type `/plan`, while this package renders no inactive plan control. 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 the warn-colored "Plan ×" status button, which 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 the plan-task hint — "describe your task to generate plan", localized through ui-conversation's `command.hint` locale namespace and shared verbatim with the claimed `/plan` command hint (rendered by the composer from the same projection; owner-supplied placeholders win).
 
 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.
 
@@ -22,4 +22,4 @@ Entering or leaving plan mode changes the active `plan:policy` system-prompt sec
 
 - **Plan mode is guidance, not an execution sandbox** — deployments that require enforced read-only planning must compose the independent sandbox and approval policies.
 - **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.
+- **No inactive plan control** — entry uses the shared Command source; a session with the capability but inactive mode shows no plan affordance in the tool row.

+ 2 - 2
packages/client/ui-plan/README.zh.md

@@ -4,7 +4,7 @@
 
 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 独立组合。
 
-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 切换为 plan 任务提示——"describe your task to generate plan"(中文「描述你的任务以生成计划」),经 ui-conversation 的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(由 composer 从同一投影渲染;owner 提供的 placeholder 优先)。
+plan mode 经 `/plan` 命令路径进入:用户可以从 composer 的 `+` Command 菜单选择 Plan,也可以输入 `/plan`,而本包(package)不渲染未激活态 plan 控件。当 host 计算的 `plan` 投影有效目标为 plan mode 时(`pending ? !active : active`——折叠的 host 值而非客户端乐观态,帧到达即自动纠正),座位渲染 warn 色的 "Plan ×" 状态按钮,该按钮经 `command.execute` 执行 `/plan off`;否则座位保持为空——未组合 plan-mode 的 host(或尚无会话的 Draft)不显示任何内容。plan mode 为有效目标期间,composer 文本框的 placeholder 切换为 plan 任务提示——"describe your task to generate plan"(中文「描述你的任务以生成计划」),经 ui-conversation 的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(由 composer 从同一投影渲染;owner 提供的 placeholder 优先)。
 
 chip 携带无障碍描述 "Plan mode on, press to turn off"。准入失败(`matched: false`、业务错误、传输故障)以内联错误呈现,chip 保持显示直至投影确认退出。
 
@@ -22,4 +22,4 @@ chip 携带无障碍描述 "Plan mode on, press to turn off"。准入失败(`m
 
 - **Plan mode 是引导而非执行沙箱**——需要强制只读规划的部署必须组合独立的沙箱与审批策略。
 - **chip 属于默认编辑器**——待处理的整编辑器交互(如 plan 评审)会临时取代 InputBar 及其 chip。
-- **无 UI 进入点**——plan mode 靠敲 `/plan` 进入;有能力但未激活的会话在工具行不显示任何入口。
+- **无未激活态 plan 控件**——入口使用共享 Command source;有能力但 mode 未激活的会话在工具行不显示 plan 入口。

+ 2 - 0
packages/client/ui-plan/package.json

@@ -38,6 +38,7 @@
     "@deepseek-ai/dsh-client-connection": "^0.0.1",
     "@deepseek-ai/dsh-client-runtime": "^0.0.1",
     "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
+    "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
     "@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
     "@deepseek-ai/dsh-invariants": "^0.0.1",
     "@deepseek-ai/dsh-plan-mode": "^0.0.1",
@@ -48,6 +49,7 @@
     "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-client-web-react": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",

+ 17 - 17
packages/client/ui-plan/src/client/PlanModeControl.module.css

@@ -1,5 +1,4 @@
-/* Plan-mode toggle chip: quiet while off; the pressed state takes the
-   business accent pair (same token pairing as the trajectory user badge). */
+/* Active plan status follows Figma's warn-state pill. */
 
 .wrap {
   display: inline-flex;
@@ -10,30 +9,25 @@
 .chip {
   display: inline-flex;
   align-items: center;
-  padding: 4px 8px;
+  gap: 4px;
+  min-width: 34px;
+  padding: 2px 8px;
   border: none;
-  border-radius: 8px;
-  background: transparent;
-  color: var(--dsw-alias-label-secondary);
-  font-size: 14px;
+  border-radius: 999px;
+  background: var(--dsw-alias-state-warn-tertiary);
+  color: var(--dsw-alias-state-warn-label);
+  font-size: 13px;
+  font-weight: 500;
   line-height: 20px;
   cursor: pointer;
 }
 
 .chip:hover:not(:disabled) {
-  background: var(--dsw-alias-interactive-bg-hover);
-}
-
-/* Hovering keeps the pressed accent: the higher-specificity hover rule above
-   would otherwise swap it back to the neutral hover wash. */
-.chip[aria-pressed='true'],
-.chip[aria-pressed='true']:hover:not(:disabled) {
-  color: var(--dsw-alias-state-business-primary);
-  background: var(--dsw-alias-state-business-tertiary);
+  color: var(--dsw-alias-state-warn-primary);
 }
 
 .chip:focus-visible {
-  outline: 2px solid var(--dsw-alias-label-secondary);
+  outline: 2px solid var(--dsw-alias-state-warn-label);
   outline-offset: 2px;
 }
 
@@ -42,6 +36,12 @@
   cursor: default;
 }
 
+.close {
+  display: inline-flex;
+  align-items: center;
+  color: currentColor;
+}
+
 .error {
   color: var(--dsw-alias-state-error-primary);
   font-size: 12px;

+ 24 - 29
packages/client/ui-plan/src/client/PlanModeControl.tsx

@@ -1,5 +1,6 @@
 import { useEffect, useRef, useState } from 'react'
 import type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import { IconCloseFill14 } from '@deepseek-ai/dsh-client-ui-primitives'
 // 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'
@@ -11,16 +12,14 @@ export type PlanChipProps =
   PropsRuntime<'conversation.input.plan'> & InjectFace<PlanChipInjected>
 
 /**
- * Plan-mode toggle over the host-computed `plan` projection. The chip renders
- * whenever the capability is present and reflects the effective target as its
- * pressed state (`pending ? !active : active` — a folded host value, not
- * client optimism, so an arriving frame corrects it). Clicking executes
- * /plan or /plan off toward the opposite target.
+ * Plan-mode status over the host-computed `plan` projection. The chip renders
+ * only while the effective target is plan mode (`pending ? !active : active`
+ * — a folded host value, not client optimism) and executes /plan off.
  */
-export function PlanChip({ useProjection, locked, setPlanMode }: PlanChipProps) {
+export function PlanChip({ useProjection, locked, exitPlanMode }: PlanChipProps) {
   const plan = useProjection('plan')
-  const [busy, setBusy] = useState(false)
-  const [error, setError] = useState<{ text: string; detail: string } | null>(null)
+  const [leaving, setLeaving] = useState(false)
+  const [error, setError] = useState<string | null>(null)
   const aliveRef = useRef(true)
 
   useEffect(() => {
@@ -30,25 +29,21 @@ export function PlanChip({ useProjection, locked, setPlanMode }: PlanChipProps)
     }
   }, [])
 
-  // Absent capability (no plan-mode host plugin / no session yet): no seat
-  // content — without the capability there is nothing to toggle.
   if (plan === undefined) return null
   const target = plan.pending ? !plan.active : plan.active
+  if (!target) return null
 
-  const toggle = (): void => {
-    // No busy/locked guard: both disable the button, so no click arrives.
-    const on = !target
-    const failText = on ? '进入 plan mode 失败' : '退出 plan mode 失败'
-    setBusy(true)
+  const off = (): void => {
+    setLeaving(true)
     setError(null)
-    void setPlanMode(on).then((failure) => {
+    void exitPlanMode().then((failure) => {
       if (!aliveRef.current) return
-      setBusy(false)
-      setError(failure === null ? null : { text: failText, detail: failure })
+      setLeaving(false)
+      setError(failure)
     }, (reason: unknown) => {
       if (!aliveRef.current) return
-      setBusy(false)
-      setError({ text: failText, detail: reason instanceof Error ? reason.message : String(reason) })
+      setLeaving(false)
+      setError(reason instanceof Error ? reason.message : String(reason))
     })
   }
 
@@ -57,17 +52,17 @@ export function PlanChip({ useProjection, locked, setPlanMode }: PlanChipProps)
       <button
         type="button"
         className={css.chip}
-        aria-pressed={target}
-        aria-label={target ? 'Plan mode on, press to turn off' : 'Plan mode off, press to turn on'}
-        title={target
-          ? 'Plan mode on — click to turn off (/plan off)'
-          : 'Plan mode off — click to turn on (/plan)'}
-        disabled={locked || busy}
-        onClick={toggle}
+        aria-label="Plan mode on, press to turn off"
+        title="Plan mode on — click to turn off (/plan off)"
+        disabled={locked || leaving}
+        onClick={off}
       >
-        Plan { target ? 'on' : 'off' }
+        Plan
+        <span className={css.close} aria-hidden>
+          <IconCloseFill14 size={12} />
+        </span>
       </button>
-      {error !== null && <span className={css.error} role="status" title={error.detail}>{error.text}</span>}
+      {error !== null && <span className={css.error} role="status" title={error}>退出 plan mode 失败</span>}
     </span>
   )
 }

+ 11 - 13
packages/client/ui-plan/src/client/index.ts

@@ -1,11 +1,11 @@
 /**
  * Plan control plugin, browser half: occupies the composer's named
- * `conversation.input.plan` seat with a plan-mode toggle chip. While the
- * `plan` projection is present the chip renders in both states and executes
- * /plan or /plan off through `command.execute` toward the opposite target;
- * an absent projection (no capability) leaves the seat empty. Reads ride the
- * generic projection pair through the standard-kit `useProjection` (an absent
- * key is capability absence); zero client-side plan state.
+ * `conversation.input.plan` seat with an active-state status chip. Plan mode
+ * is entered through the command source; while the projection's effective
+ * target is plan mode the chip renders and executes /plan off through
+ * `command.execute`, otherwise the seat stays empty. Reads ride the generic
+ * projection pair through the standard-kit `useProjection`; 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'
@@ -18,11 +18,10 @@ import { PlanChip } from './PlanModeControl.tsx'
 /** Injected business face of the composer plan seat. */
 export interface PlanChipInjected {
   /**
-   * Switch plan mode by executing /plan (on) or /plan off.
-   * @param on - desired target: true enters plan mode, false leaves it.
+   * Leave plan mode by executing /plan off.
    * @returns null on admitted execution; a user-visible failure line otherwise.
    */
-  setPlanMode: (on: boolean) => Promise<string | null>
+  exitPlanMode: () => Promise<string | null>
 }
 
 /**
@@ -39,12 +38,11 @@ export function apply(ctx: ClientContext): void {
   ctx.effect(() => ctx.slots.register({
     name: 'conversation.input.plan',
     inject: (sessionId: SessionId): PlanChipInjected => ({
-      setPlanMode: async (on) => {
-        const line = on ? '/plan' : '/plan off'
+      exitPlanMode: async () => {
         const connection = ctx.get('connection') as ConnectionHandle
-        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
       },
     }),

+ 8 - 10
packages/client/ui-plan/tests/browser-plugin.spec.ts

@@ -1,9 +1,9 @@
 /**
  * ui-plan browser half on a real SlotsService: the plugin occupies the
- * conversation-declared `conversation.input.plan` single seat with the plan
- * toggle chip; the injected face executes /plan or /plan off by direction and
- * folds admission outcomes into null (admitted) or a user-visible failure
- * line; teardown empties the seat (HMR safety).
+ * conversation-declared `conversation.input.plan` single seat with the active
+ * 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).
  */
 import { Context } from 'cordis'
 import { describe, expect, it, vi } from 'vitest'
@@ -49,7 +49,7 @@ describe('ui-plan browser apply', () => {
       .rejects.toThrow(/slot "conversation.input.plan" is not declared/)
   })
 
-  it('registers the chip, executes /plan by direction, 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()
@@ -57,22 +57,20 @@ describe('ui-plan browser apply', () => {
     expect(entry.component).toBe(PlanChip)
     const injected = (entry.inject as unknown as (id: SessionId) => PlanChipInjected)(SID)
 
-    await expect(injected.setPlanMode(false)).resolves.toBeNull()
+    await expect(injected.exitPlanMode()).resolves.toBeNull()
     expect(b.execute).toHaveBeenLastCalledWith({ sessionId: SID, line: '/plan off' })
-    await expect(injected.setPlanMode(true)).resolves.toBeNull()
-    expect(b.execute).toHaveBeenLastCalledWith({ sessionId: SID, line: '/plan' })
 
     // 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(false)).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)

+ 37 - 63
packages/client/ui-plan/tests/plan-mode-control.spec.tsx

@@ -1,11 +1,9 @@
 // @vitest-environment jsdom
 /**
  * PlanChip over the `plan` projection: nothing renders while the capability
- * is absent; with the capability present the chip renders in both states with
- * aria-pressed following the effective target (pending folds — /plan shows
- * pressed immediately, /plan off unpressed immediately); clicking executes
- * the command toward the opposite target and surfaces direction-specific
- * failures while the projection still owns the displayed state.
+ * is absent or the effective target is the default mode; while plan mode is
+ * the target, the chip executes /plan off and remains visible through failures
+ * until the projection confirms the exit.
  */
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
@@ -18,98 +16,74 @@ afterEach(cleanup)
 
 function setup(
   plan: PlanProjection | undefined,
-  setPlanMode = vi.fn((_on: boolean) => Promise.resolve<string | null>(null)),
+  exitPlanMode = vi.fn(() => Promise.resolve<string | null>(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 PlanChipProps
+  const props = { useProjection, locked, exitPlanMode } as unknown as PlanChipProps
   const view = render(<PlanChip {...props} />)
-  return { store, setPlanMode, view }
+  return { store, exitPlanMode, view }
 }
 
-const onChip = () => screen.getByRole('button', { name: 'Plan mode on, press to turn off' })
-const offChip = () => screen.getByRole('button', { name: 'Plan mode off, press to turn on' })
+const chip = () => screen.getByRole('button', { name: 'Plan mode on, press to turn off' })
 
 describe('PlanChip', () => {
-  it('renders nothing while the capability is absent', () => {
+  it('renders nothing for an absent capability or a default-mode target', () => {
     const absent = setup(undefined)
     expect(absent.view.container.innerHTML).toBe('')
+    cleanup()
+    const inactive = setup({ active: false, pending: false })
+    expect(inactive.view.container.innerHTML).toBe('')
+    cleanup()
+    const leaving = setup({ active: true, pending: true })
+    expect(leaving.view.container.innerHTML).toBe('')
   })
 
-  it('reflects the effective target as the pressed state, folding pending', () => {
-    setup({ active: false, pending: false })
-    expect(offChip().getAttribute('aria-pressed')).toBe('false')
-    cleanup()
+  it('renders the Plan status for active and pending-entry targets', () => {
     setup({ active: true, pending: false })
-    expect(onChip().getAttribute('aria-pressed')).toBe('true')
+    expect(chip().textContent).toBe('Plan')
     cleanup()
-    // /plan just ran (command/run folded, plan/mode not yet): target is plan.
     setup({ active: false, pending: true })
-    expect(onChip().getAttribute('aria-pressed')).toBe('true')
-    cleanup()
-    // Active with a pending exit: the target is default — already unpressed.
-    setup({ active: true, pending: true })
-    expect(offChip().getAttribute('aria-pressed')).toBe('false')
+    expect(chip().textContent).toBe('Plan')
   })
 
-  it('unpressed chip executes /plan (on) once and follows the projection up', async () => {
+  it('executes /plan off once and follows the projection down', async () => {
     let resolve!: (value: string | null) => void
-    const setPlanMode = vi.fn((_on: boolean) => new Promise<string | null>((done) => { resolve = done }))
-    const { store } = setup({ active: false, pending: false }, setPlanMode)
-    fireEvent.click(offChip())
-    expect(setPlanMode).toHaveBeenCalledTimes(1)
-    expect(setPlanMode).toHaveBeenLastCalledWith(true)
-    // Busy while its own call is in flight.
-    fireEvent.click(offChip())
-    expect(setPlanMode).toHaveBeenCalledTimes(1)
+    const exitPlanMode = vi.fn(() => new Promise<string | null>((done) => { resolve = done }))
+    const { store } = setup({ active: true, pending: false }, exitPlanMode)
+    fireEvent.click(chip())
+    expect(exitPlanMode).toHaveBeenCalledTimes(1)
+    fireEvent.click(chip())
+    expect(exitPlanMode).toHaveBeenCalledTimes(1)
     resolve(null)
-    // The command's run record folds: target flips, the chip presses.
-    store.set({ value: { active: false, pending: true } })
-    await waitFor(() => {
-      expect(onChip().getAttribute('aria-pressed')).toBe('true')
-    })
-  })
-
-  it('pressed chip executes /plan off and follows the projection down', async () => {
-    const setPlanMode = vi.fn((_on: boolean) => Promise.resolve<string | null>(null))
-    const { store } = setup({ active: true, pending: false }, setPlanMode)
-    fireEvent.click(onChip())
-    expect(setPlanMode).toHaveBeenLastCalledWith(false)
     store.set({ value: { active: true, pending: true } })
     await waitFor(() => {
-      expect(offChip().getAttribute('aria-pressed')).toBe('false')
+      expect(screen.queryByRole('button', { name: 'Plan mode on, press to turn off' })).toBeNull()
     })
   })
 
   it('disables under the locked owner prop', () => {
     setup({ active: true, pending: false }, vi.fn(), true)
-    expect((onChip() as HTMLButtonElement).disabled).toBe(true)
+    expect((chip() as HTMLButtonElement).disabled).toBe(true)
   })
 
-  it('surfaces direction-specific admission and transport failures while staying visible', async () => {
-    const exitFailing = 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: true, pending: false }, exitFailing)
-    fireEvent.click(onChip())
+    setup({ active: true, pending: false }, exitPlanMode)
+    fireEvent.click(chip())
     expect((await screen.findByText('退出 plan mode 失败')).getAttribute('title')).toBe('host said no')
-    expect(onChip()).toBeTruthy()
+    expect(chip()).toBeTruthy()
 
-    fireEvent.click(onChip())
+    fireEvent.click(chip())
     expect(await screen.findByTitle('network down')).toBeTruthy()
 
-    fireEvent.click(onChip())
+    fireEvent.click(chip())
     expect(await screen.findByTitle('socket closed')).toBeTruthy()
-    cleanup()
-
-    const enterFailing = vi.fn().mockResolvedValueOnce('agent busy')
-    setup({ active: false, pending: false }, enterFailing)
-    fireEvent.click(offChip())
-    expect((await screen.findByText('进入 plan mode 失败')).getAttribute('title')).toBe('agent busy')
-    expect(offChip()).toBeTruthy()
   })
 
   it('ignores in-flight fulfillment and rejection after unmount', () => {
@@ -118,14 +92,14 @@ describe('PlanChip', () => {
       { active: true, pending: false },
       vi.fn(() => new Promise<string | null>((done) => { resolve = done })),
     )
-    fireEvent.click(onChip())
+    fireEvent.click(chip())
     successful.view.unmount()
     expect(() => { resolve(null) }).not.toThrow()
 
     let reject!: (reason: unknown) => void
-    const setPlanMode = vi.fn(() => new Promise<string | null>((_done, fail) => { reject = fail }))
-    const { view } = setup({ active: true, pending: false }, setPlanMode)
-    fireEvent.click(onChip())
+    const exitPlanMode = vi.fn(() => new Promise<string | null>((_done, fail) => { reject = fail }))
+    const { view } = setup({ active: true, pending: false }, exitPlanMode)
+    fireEvent.click(chip())
     view.unmount()
     expect(() => { reject(new Error('late')) }).not.toThrow()
   })

+ 3 - 0
packages/client/ui-plan/tsconfig.json

@@ -20,6 +20,9 @@
     {
       "path": "../ui-conversation"
     },
+    {
+      "path": "../ui-primitives"
+    },
     {
       "path": "../ui-slots"
     },

+ 3 - 0
pnpm-lock.yaml

@@ -1367,6 +1367,9 @@ importers:
       '@deepseek-ai/dsh-client-ui-conversation':
         specifier: workspace:^
         version: link:../ui-conversation
+      '@deepseek-ai/dsh-client-ui-primitives':
+        specifier: workspace:^
+        version: link:../ui-primitives
       '@deepseek-ai/dsh-client-ui-slots':
         specifier: workspace:^
         version: link:../ui-slots