diff --git a/.agents/notes/implemented/feature/2026-07-24-web-plan-mode.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-web-plan-mode.i18n.yaml index 6e3bbade75..34091877bf 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-plan-mode.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-web-plan-mode.i18n.yaml @@ -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 -2026-07-24-web-plan-mode.md: 42407198c09c34f2ddde559f5ddeae8d7736169e -2026-07-24-web-plan-mode.zh.md: db865a26e91977670ed520aecf9d683a6bea5dab +2026-07-24-web-plan-mode.md: 3b8a14a279dee478a291b19d5c0aed82f5a16c5c +2026-07-24-web-plan-mode.zh.md: ca52918257c289fab966f1915348b08de45d86f1 diff --git a/.agents/notes/implemented/feature/2026-07-24-web-plan-mode.md b/.agents/notes/implemented/feature/2026-07-24-web-plan-mode.md index 42407198c0..3b8a14a279 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-plan-mode.md +++ b/.agents/notes/implemented/feature/2026-07-24-web-plan-mode.md @@ -18,7 +18,7 @@ The policy is product-owned configuration at this composition boundary. It tells `ui-conversation` owns and renders the new additive controls slot to the left of the primary composer action. It provides no business payload; entries receive the standard session kit. Whole-composer replacements remain on the separate selector-routed `conversation.composer` chain, so a pending question replaces the InputBar and its controls without either feature importing the other. -The control is absent when `planMode` is `null`. Otherwise, its selected value is `pending ?? active`, while pending presentation tests field presence rather than truthiness. It displays `计划 · 待生效` or `默认 · 待生效` until a logged commit replaces the snapshot. A selection disables only the selector while its own RPC is in flight. Generation does not disable it: selecting during a running turn neither calls cancel nor changes that turn, and cancelling generation does not clear the pending target. +The control is absent when `planMode` is `null`. Otherwise, its selected value is `pending ?? active`, while pending presentation tests field presence rather than truthiness. It displays `计划 · 待生效` or `默认 · 待生效` until a logged commit replaces the snapshot. The transparent native select mirrors focus onto the visible chip and references a dynamic accessible description that distinguishes committed mode from the pending target. A selection disables only the selector while its own RPC is in flight. Generation does not disable it: selecting during a running turn neither calls cancel nor changes that turn, and cancelling generation does not clear the pending target. ## Interaction semantics diff --git a/.agents/notes/implemented/feature/2026-07-24-web-plan-mode.zh.md b/.agents/notes/implemented/feature/2026-07-24-web-plan-mode.zh.md index db865a26e9..ca52918257 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-plan-mode.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-web-plan-mode.zh.md @@ -18,7 +18,7 @@ Web 宿主可以通过[可选会话 RPC 契约](../architecture/2026-07-24-web-p `ui-conversation` 拥有并渲染新增的可叠加控件槽,其位置在 composer 主操作左侧。该槽不提供业务载荷;各入口接收标准会话注入项。替换整个 composer 的功能仍走另一条由选择器路由的 `conversation.composer` 链,因此待处理的问题会替换 InputBar 及其控件,两个功能均无需导入对方。 -当 `planMode` 为 `null` 时,控件不会出现。否则,其选中值为 `pending ?? active`,而待生效状态的呈现依据是字段是否存在,而非字段真值。在日志提交替换快照前,控件会显示 `计划 · 待生效` 或 `默认 · 待生效`。选择操作只会在自身 RPC 执行期间禁用选择器。生成过程不会禁用该控件:在运行中的轮次里选择模式,既不会调用取消,也不会改变该轮次;取消生成也不会清除待生效目标。 +当 `planMode` 为 `null` 时,控件不会出现。否则,其选中值为 `pending ?? active`,而待生效状态的呈现依据是字段是否存在,而非字段真值。在日志提交替换快照前,控件会显示 `计划 · 待生效` 或 `默认 · 待生效`。透明的原生 select 会将焦点状态映射到可见的 chip,并引用一条动态无障碍描述,以区分已提交模式和待生效目标。选择操作只会在自身 RPC 执行期间禁用选择器。生成过程不会禁用该控件:在运行中的轮次里选择模式,既不会调用取消,也不会改变该轮次;取消生成也不会清除待生效目标。 ## 交互语义 diff --git a/apps/web/tests/smoke-fixture.e2e.ts b/apps/web/tests/smoke-fixture.e2e.ts index 2a847eea40..0c6776cf5e 100644 --- a/apps/web/tests/smoke-fixture.e2e.ts +++ b/apps/web/tests/smoke-fixture.e2e.ts @@ -252,9 +252,20 @@ describe('web boot chain success pass (keyless, ten real bundles, ?fixture)', () const select = page.getByRole('combobox', { name: '协作模式' }) await select.waitFor({ state: 'visible', timeout: 15_000 }) await expect(page.getByTitle('当前为默认模式').isVisible()).resolves.toBe(true) + await select.focus() + expect(await select.evaluate((element) => { + const chip = element.parentElement?.firstElementChild + if (!(chip instanceof HTMLElement)) return null + const style = getComputedStyle(chip) + return { outlineStyle: style.outlineStyle, outlineWidth: style.outlineWidth } + })).toEqual({ outlineStyle: 'solid', outlineWidth: '2px' }) await select.selectOption('plan') await page.getByTitle(/计划模式将在下一次模型请求时生效/).waitFor() + const descriptionId = await select.getAttribute('aria-describedby') + expect(descriptionId).not.toBeNull() + expect(await page.locator(`[id="${descriptionId}"]`).textContent()) + .toBe('当前为默认模式;计划模式将在下一次模型请求时生效') const input = page.locator('textarea[placeholder]') await input.fill('commit plan mode') await page.getByRole('button', { name: '发送' }).click() diff --git a/packages/client/ui-plan/README.md b/packages/client/ui-plan/README.md index 17ad26ce6b..2a2a4478cd 100644 --- a/packages/client/ui-plan/README.md +++ b/packages/client/ui-plan/README.md @@ -2,7 +2,7 @@ Web plan-mode feature with two lifecycle-coupled halves. The node entry mounts `@deepseek-ai/dsh-plan-mode` with the Web product policy; the browser entry contributes a session-scoped selector to `conversation.composer.controls`. -The selector distinguishes unavailable capability (`planMode === null`), committed mode (`active`), and the target queued for the next model-request boundary (`pending`, including `pending: false`). Selecting a mode never cancels a running turn. It remains available while generation is running, disables only during its own RPC, and displays the host-confirmed pending target until a logged `plan/mode` event commits it. +The selector distinguishes unavailable capability (`planMode === null`), committed mode (`active`), and the target queued for the next model-request boundary (`pending`, including `pending: false`). Selecting a mode never cancels a running turn. It remains available while generation is running, disables only during its own RPC, and displays the host-confirmed pending target until a logged `plan/mode` event commits it. The transparent native select mirrors keyboard focus onto the visible chip and carries a dynamic accessible description of the committed and pending modes. The model exits plan mode through the stable `exit_plan_mode` tool. Its plan review uses the composed Web question channel: approval schedules default mode for the next step, while rejection or custom feedback keeps plan mode active and returns the feedback to the model. diff --git a/packages/client/ui-plan/src/client/PlanModeControl.module.css b/packages/client/ui-plan/src/client/PlanModeControl.module.css index 8c679fe7c9..52bf367e3e 100644 --- a/packages/client/ui-plan/src/client/PlanModeControl.module.css +++ b/packages/client/ui-plan/src/client/PlanModeControl.module.css @@ -29,10 +29,27 @@ background: var(--dsw-alias-interactive-bg-hover); } +.root:focus-within .chip { + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: 2px; +} + .chevron { color: var(--dsw-alias-label-caption); } +.description { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + .select { position: absolute; inset: 0; diff --git a/packages/client/ui-plan/src/client/PlanModeControl.tsx b/packages/client/ui-plan/src/client/PlanModeControl.tsx index a7f73e0590..d48b1f323e 100644 --- a/packages/client/ui-plan/src/client/PlanModeControl.tsx +++ b/packages/client/ui-plan/src/client/PlanModeControl.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useId, useRef, useState } from 'react' import type { PlanModeControlProps } from './index.ts' import css from './PlanModeControl.module.css' @@ -13,6 +13,7 @@ export function PlanModeControl({ useSession, setPlanMode }: PlanModeControlProp const [switching, setSwitching] = useState(false) const [error, setError] = useState(null) const aliveRef = useRef(true) + const descriptionId = useId() useEffect(() => { aliveRef.current = true @@ -57,9 +58,11 @@ export function PlanModeControl({ useSession, setPlanMode }: PlanModeControlProp + {title}