fix(web): expose plan selector state accessibly

This commit is contained in:
fz
2026-07-24 13:30:03 +08:00
parent a0748ce560
commit cb633fd357
8 changed files with 48 additions and 8 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-07-24-web-plan-mode.md: 42407198c09c34f2ddde559f5ddeae8d7736169e 2026-07-24-web-plan-mode.md: 3b8a14a279dee478a291b19d5c0aed82f5a16c5c
2026-07-24-web-plan-mode.zh.md: db865a26e91977670ed520aecf9d683a6bea5dab 2026-07-24-web-plan-mode.zh.md: ca52918257c289fab966f1915348b08de45d86f1
@@ -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. `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 ## Interaction semantics
@@ -18,7 +18,7 @@ Web 宿主可以通过[可选会话 RPC 契约](../architecture/2026-07-24-web-p
`ui-conversation` 拥有并渲染新增的可叠加控件槽,其位置在 composer 主操作左侧。该槽不提供业务载荷;各入口接收标准会话注入项。替换整个 composer 的功能仍走另一条由选择器路由的 `conversation.composer` 链,因此待处理的问题会替换 InputBar 及其控件,两个功能均无需导入对方。 `ui-conversation` 拥有并渲染新增的可叠加控件槽,其位置在 composer 主操作左侧。该槽不提供业务载荷;各入口接收标准会话注入项。替换整个 composer 的功能仍走另一条由选择器路由的 `conversation.composer` 链,因此待处理的问题会替换 InputBar 及其控件,两个功能均无需导入对方。
当 `planMode` 为 `null` 时,控件不会出现。否则,其选中值为 `pending ?? active`,而待生效状态的呈现依据是字段是否存在,而非字段真值。在日志提交替换快照前,控件会显示 `计划 · 待生效` 或 `默认 · 待生效`。选择操作只会在自身 RPC 执行期间禁用选择器。生成过程不会禁用该控件:在运行中的轮次里选择模式,既不会调用取消,也不会改变该轮次;取消生成也不会清除待生效目标。 当 `planMode` 为 `null` 时,控件不会出现。否则,其选中值为 `pending ?? active`,而待生效状态的呈现依据是字段是否存在,而非字段真值。在日志提交替换快照前,控件会显示 `计划 · 待生效` 或 `默认 · 待生效`。透明的原生 select 会将焦点状态映射到可见的 chip,并引用一条动态无障碍描述,以区分已提交模式和待生效目标。选择操作只会在自身 RPC 执行期间禁用选择器。生成过程不会禁用该控件:在运行中的轮次里选择模式,既不会调用取消,也不会改变该轮次;取消生成也不会清除待生效目标。
## 交互语义 ## 交互语义
+11
View File
@@ -252,9 +252,20 @@ describe('web boot chain success pass (keyless, ten real bundles, ?fixture)', ()
const select = page.getByRole('combobox', { name: '协作模式' }) const select = page.getByRole('combobox', { name: '协作模式' })
await select.waitFor({ state: 'visible', timeout: 15_000 }) await select.waitFor({ state: 'visible', timeout: 15_000 })
await expect(page.getByTitle('当前为默认模式').isVisible()).resolves.toBe(true) 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 select.selectOption('plan')
await page.getByTitle(/计划模式将在下一次模型请求时生效/).waitFor() 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]') const input = page.locator('textarea[placeholder]')
await input.fill('commit plan mode') await input.fill('commit plan mode')
await page.getByRole('button', { name: '发送' }).click() await page.getByRole('button', { name: '发送' }).click()
+1 -1
View File
@@ -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`. 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. 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.
@@ -29,10 +29,27 @@
background: var(--dsw-alias-interactive-bg-hover); background: var(--dsw-alias-interactive-bg-hover);
} }
.root:focus-within .chip {
outline: 2px solid var(--dsw-alias-brand-primary);
outline-offset: 2px;
}
.chevron { .chevron {
color: var(--dsw-alias-label-caption); 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 { .select {
position: absolute; position: absolute;
inset: 0; inset: 0;
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useId, useRef, useState } from 'react'
import type { PlanModeControlProps } from './index.ts' import type { PlanModeControlProps } from './index.ts'
import css from './PlanModeControl.module.css' import css from './PlanModeControl.module.css'
@@ -13,6 +13,7 @@ export function PlanModeControl({ useSession, setPlanMode }: PlanModeControlProp
const [switching, setSwitching] = useState(false) const [switching, setSwitching] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const aliveRef = useRef(true) const aliveRef = useRef(true)
const descriptionId = useId()
useEffect(() => { useEffect(() => {
aliveRef.current = true aliveRef.current = true
@@ -57,9 +58,11 @@ export function PlanModeControl({ useSession, setPlanMode }: PlanModeControlProp
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" /> <path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" />
</svg> </svg>
</span> </span>
<span id={descriptionId} className={css.description}>{title}</span>
<select <select
className={css.select} className={css.select}
aria-label="协作模式" aria-label="协作模式"
aria-describedby={descriptionId}
value={value} value={value}
disabled={switching} disabled={switching}
onChange={(event) => { select(event.target.value === 'plan') }} onChange={(event) => { select(event.target.value === 'plan') }}
@@ -40,17 +40,26 @@ describe('PlanModeControl', () => {
cleanup() cleanup()
setup({ active: false }) setup({ active: false })
expect(screen.getByTitle('当前为默认模式')).toBeTruthy() expect(screen.getByTitle('当前为默认模式')).toBeTruthy()
expect((screen.getByRole('combobox', { name: '协作模式' }) as HTMLSelectElement).value).toBe('default') const select = screen.getByRole('combobox', { name: '协作模式' }) as HTMLSelectElement
expect(select.value).toBe('default')
expect(document.getElementById(select.getAttribute('aria-describedby') ?? '')?.textContent)
.toBe('当前为默认模式')
}) })
it('treats pending field presence as the target, including pending false', () => { it('treats pending field presence as the target, including pending false', () => {
setup({ active: false, pending: true }) setup({ active: false, pending: true })
expect(screen.getByText('计划 · 待生效')).toBeTruthy() expect(screen.getByText('计划 · 待生效')).toBeTruthy()
expect(screen.getByTitle(/当前为默认模式/)).toBeTruthy() expect(screen.getByTitle(/当前为默认模式/)).toBeTruthy()
const planSelect = screen.getByRole('combobox')
expect(document.getElementById(planSelect.getAttribute('aria-describedby') ?? '')?.textContent)
.toBe('当前为默认模式;计划模式将在下一次模型请求时生效')
cleanup() cleanup()
setup({ active: true, pending: false }) setup({ active: true, pending: false })
expect(screen.getByText('默认 · 待生效')).toBeTruthy() expect(screen.getByText('默认 · 待生效')).toBeTruthy()
expect((screen.getByRole('combobox') as HTMLSelectElement).value).toBe('default') const defaultSelect = screen.getByRole('combobox') as HTMLSelectElement
expect(defaultSelect.value).toBe('default')
expect(document.getElementById(defaultSelect.getAttribute('aria-describedby') ?? '')?.textContent)
.toBe('当前为计划模式;默认模式将在下一次模型请求时生效')
}) })
it('switches from the effective target and remains available while a turn runs', async () => { it('switches from the effective target and remains available while a turn runs', async () => {