diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml index ea630a99f9..09ab4376b5 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml +++ b/.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 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md index 2793e9045f..c3deadb34d 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md +++ b/.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 | diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md index e3a35c4e55..7a6988423d 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md +++ b/.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 链保留给需要不可分原子性的场景 | diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 746cd37e36..97ffefccf7 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/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', ]) }) }) diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index 31476daefd..7b957e5899 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/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 diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index 4425921fdf..5abf911c83 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/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 diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index 64c62f85d6..ec99a493f3 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/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 diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index 783964ed31..bed9df014c 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/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 diff --git a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md new file mode 100644 index 0000000000..8bb1351e0f --- /dev/null +++ b/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: 点击消息流中的工具行查看详情 diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index 81f1ab608b..b323b67a30 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/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 diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index f65b090a16..2b14062211 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/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 diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index 0d013f819d..364cf54ddb 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/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 diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index 11bb665e71..c008fc4dba 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/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 diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md index b15a665c45..369f03c6f1 100644 --- a/apps/web/tests/snapshots/message-actions/ui.expected.md +++ b/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 diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index db0c2cfd3a..ec9a074e72 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/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 diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md index 7e67544f04..0f890e271f 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/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 diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md index 48c288909c..81144381cf 100644 --- a/apps/web/tests/snapshots/queue-actions/ui.expected.md +++ b/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 diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index c520dafae9..8b6a393664 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/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 diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index 1172d3ca5d..193f19ef05 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/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 diff --git a/packages/client/ui-plan/README.i18n.yaml b/packages/client/ui-plan/README.i18n.yaml index 199210a863..c1abb744cc 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: 1d22c057b439ff337bf9daadcdba96dd4cca4540 -README.zh.md: 183b8ef7776b60c1f0afa630e04627a474d40391 +README.md: 2f83dd738e11ada5b24a55d1eec97e154ffd2aea +README.zh.md: a026390191179f6492a55ee931a3406d8e1953e8 diff --git a/packages/client/ui-plan/README.md b/packages/client/ui-plan/README.md index 1d22c057b4..2f83dd738e 100644 --- a/packages/client/ui-plan/README.md +++ b/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. diff --git a/packages/client/ui-plan/README.zh.md b/packages/client/ui-plan/README.zh.md index 183b8ef777..a026390191 100644 --- a/packages/client/ui-plan/README.zh.md +++ b/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 入口。 diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index a2aec47985..d7524cbc7d 100644 --- a/packages/client/ui-plan/package.json +++ b/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:^", diff --git a/packages/client/ui-plan/src/client/PlanModeControl.module.css b/packages/client/ui-plan/src/client/PlanModeControl.module.css index f79e9073db..46e893aee8 100644 --- a/packages/client/ui-plan/src/client/PlanModeControl.module.css +++ b/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; diff --git a/packages/client/ui-plan/src/client/PlanModeControl.tsx b/packages/client/ui-plan/src/client/PlanModeControl.tsx index 961d7435ef..a6fc74f77b 100644 --- a/packages/client/ui-plan/src/client/PlanModeControl.tsx +++ b/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 /** - * 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(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) - {error !== null && {error.text}} + {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 77779efb02..d85e508922 100644 --- a/packages/client/ui-plan/src/client/index.ts +++ b/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 + exitPlanMode: () => Promise } /** @@ -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 }, }), diff --git a/packages/client/ui-plan/tests/browser-plugin.spec.ts b/packages/client/ui-plan/tests/browser-plugin.spec.ts index 1051d52777..aa0bf35b55 100644 --- a/packages/client/ui-plan/tests/browser-plugin.spec.ts +++ b/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) 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 63f6bd7a6e..2489b53be0 100644 --- a/packages/client/ui-plan/tests/plan-mode-control.spec.tsx +++ b/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(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 PlanChipProps + const props = { useProjection, locked, exitPlanMode } as unknown as PlanChipProps const view = render() - 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((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((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(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((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((_done, fail) => { reject = fail })) - const { view } = setup({ active: true, pending: false }, setPlanMode) - fireEvent.click(onChip()) + 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/packages/client/ui-plan/tsconfig.json b/packages/client/ui-plan/tsconfig.json index 4ab13662f2..23ce289533 100644 --- a/packages/client/ui-plan/tsconfig.json +++ b/packages/client/ui-plan/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../ui-conversation" }, + { + "path": "../ui-primitives" + }, { "path": "../ui-slots" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a014fa2333..58fc06888f 100644 --- a/pnpm-lock.yaml +++ b/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