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

View File

@@ -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

View File

@@ -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

View File

@@ -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 执行期间禁用选择器。生成过程不会禁用该控件:在运行中的轮次里选择模式,既不会调用取消,也不会改变该轮次;取消生成也不会清除待生效目标。
## 交互语义

View File

@@ -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()

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`.
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.

View File

@@ -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;

View File

@@ -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<string | null>(null)
const aliveRef = useRef(true)
const descriptionId = useId()
useEffect(() => {
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" />
</svg>
</span>
<span id={descriptionId} className={css.description}>{title}</span>
<select
className={css.select}
aria-label="协作模式"
aria-describedby={descriptionId}
value={value}
disabled={switching}
onChange={(event) => { select(event.target.value === 'plan') }}

View File

@@ -40,17 +40,26 @@ describe('PlanModeControl', () => {
cleanup()
setup({ active: false })
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', () => {
setup({ active: false, pending: true })
expect(screen.getByText('计划 · 待生效')).toBeTruthy()
expect(screen.getByTitle(/当前为默认模式/)).toBeTruthy()
const planSelect = screen.getByRole('combobox')
expect(document.getElementById(planSelect.getAttribute('aria-describedby') ?? '')?.textContent)
.toBe('当前为默认模式;计划模式将在下一次模型请求时生效')
cleanup()
setup({ active: true, pending: false })
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 () => {