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,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 () => {