Merge pull request #3326 from deepseek-harness/worktree-rerevert2608

fix(web): restore localized permission labels
This commit is contained in:
imccyu 2026-08-30 14:55:28 +08:00 • committed by GitHub
commit 9e1bdefc72
19 changed files with 221 additions and 114 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 .agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.md
2026-07-31-gui-full-access-confirmation.md: f63502cd3e2306f36b136e6ed8543641449c3d83
2026-07-31-gui-full-access-confirmation.zh.md: f4b3686d1e1ad9e51a08e513a7dd5930d311582d
2026-07-31-gui-full-access-confirmation.md: c0ae295c312e390b47395bdd09da4317e8ff6c81
2026-07-31-gui-full-access-confirmation.zh.md: 1679fc5060a5f175e61383d31d76652556227de4

View file

@ -10,13 +10,13 @@ Switching the web client to `danger-full-access` was a single click on a permiss
## Decision
**Every permission picker gates `danger-full-access` behind the shared in-page `RiskConfirmation` dialog whose enabling action stays disabled until an explicit acknowledgement checkbox is checked; the preset renders under the product label `Full access`; every dismissal path submits nothing.**
**Every permission picker gates `danger-full-access` behind the shared in-page `RiskConfirmation` dialog whose enabling action stays disabled until an explicit acknowledgement checkbox is checked; the preset renders under its locale-owned product label; every dismissal path submits nothing.**
- `RiskConfirmation` (ui-primitives) is a controlled Modal composition: title, description, acknowledgement checkbox, cancel, and a confirm button disabled until `acknowledged`. It stays an in-page dialog — the Modal portals to this document's body and never opens a native or separate browser window that could land on another display. `Modal` gains a `contentClassName` seat so the warning body scrolls inside constrained mobile/landscape viewports while the action row stays fixed.
- The composer chip (`PermissionSelect`, ui-conversation) intercepts a Full-access pick before the `/permission` submit: `confirmation`/`acknowledged` component state opens the dialog, confirm submits `/permission danger-full-access` through the same injected `command` path as every other pick, and cancel/Escape/close/mask leave the current preset untouched with the checkbox reset. The confirmation revokes itself when the session locks (`locked`/value-absent effect) and resets across task switches (`key={sessionId}` remount). Copy rides the standard `conversation` locale seat as `access.confirm.*` keys.
- The `/permission` popup (ui-permission over the ui-commands shell) gates through data, not a second dialog implementation: `SelectOption` grows an optional `confirmation` payload, the popup controller owns the `confirming`/`acknowledged` state transitions, and `PopupSelectView` swaps the picker card for the same `RiskConfirmation` while a gated option is pending.
- The General-settings Permission row uses the same controlled `RiskConfirmation` before persisting Full access as the default for later sessions. Its warning names that future-session lifetime; cancel, Escape, close, and mask dismissal leave the stored default untouched.
- `Full access` intentionally overrides the kebab-to-title display transform in every picker; command and Settings writes keep the machine name on the wire, and each warning body remains locale-aware in Chinese and English.
- Canonical built-in preset names render through each picker's locale dictionary (`Full access` in English and `完全权限` in Chinese), while explicit host labels remain unchanged. Command and Settings writes keep the machine name on the wire, and each warning body remains locale-aware in Chinese and English.
## Alternatives considered

View file

@ -10,13 +10,13 @@ Status: implemented
## 决策
**每个权限选择器都把 `danger-full-access` 关进共享的页面内 `RiskConfirmation` 对话框:启用按钮在用户勾选明确的风险确认复选框前保持禁用;预设以产品标签 `Full access` 展示;所有取消路径都不作任何提交。**
**每个权限选择器都把 `danger-full-access` 关进共享的页面内 `RiskConfirmation` 对话框:启用按钮在用户勾选明确的风险确认复选框前保持禁用;预设以 locale 所有的产品标签展示;所有取消路径都不作任何提交。**
- `RiskConfirmation`(ui-primitives)是受控的 Modal 组合:标题、说明、确认复选框、取消,以及 `acknowledged` 勾选前禁用的确认按钮。它始终是页面内对话框——Modal portal 到本文档 body,绝不打开可能落在另一块显示器上的原生或独立浏览器窗口。`Modal` 新增 `contentClassName` slot,令警示正文在受限的移动端/横屏视口内滚动,动作行保持固定。
- composer chip(ui-conversation 的 `PermissionSelect`)在 `/permission` 提交前拦截 Full-access 选择:`confirmation`/`acknowledged` 组件状态打开对话框,确认后经与其他选择完全相同的注入 `command` 通道提交 `/permission danger-full-access`;取消、Escape、关闭与遮罩点击均保持当前预设不变并重置复选框。会话锁定时确认自行撤销(`locked`/值缺席 effect),切换任务时随 `key={sessionId}` 重挂载而重置。文案经标准 `conversation` locale slot 以 `access.confirm.*` 键供给。
- `/permission` popup(ui-permission 构建于 ui-commands 外壳之上)以数据而非第二套对话框实现完成把关:`SelectOption` 新增可选的 `confirmation` 载荷,popup 控制器拥有 `confirming`/`acknowledged` 状态迁移,`PopupSelectView` 在门控选项未决期间把选择卡换成同一个 `RiskConfirmation`。
- 「通用」设置中的「权限」行在把 Full access 持久化为后续会话的默认值前,也使用同一个受控 `RiskConfirmation`。警示会明确说明该设置只影响后续会话;取消、Escape、关闭与点击遮罩均不会改动已存默认值。
- `Full access` 在每个选择器中都有意覆盖 kebab 转 Title Case 的显示变换;命令与 Settings 写入在 wire 上保留机器名,每份警示正文都保持中英文 locale 感知。
- 规范内置预设名通过每个选择器的 locale 词典呈现(英文为 `Full access`,中文为「完全权限」),显式 host 标签保持原样。命令与 Settings 写入在 wire 上保留机器名,每份警示正文都保持中英文 locale 感知。
## 考虑过的替代方案

View file

@ -50,13 +50,13 @@ describe('web e2e: Full access confirmation', () => {
const access = page.locator('button[aria-label^="访问模式"]').first()
await access.waitFor({ timeout: 10_000 })
expect(await access.getAttribute('aria-label')).toBe('访问模式,当前:Workspace Write')
expect(await access.getAttribute('aria-label')).toBe('访问模式,当前:可写入工作区')
await access.click()
await page.getByRole('menuitem', { name: 'Full access' }).click()
const dialog = page.getByRole('dialog', { name: '确认启用 Full access?' })
await page.getByRole('menuitem', { name: '完全权限' }).click()
const dialog = page.getByRole('dialog', { name: '确认启用完全权限?' })
await dialog.waitFor({ timeout: 10_000 })
const enable = dialog.getByRole('button', { name: '启用 Full access' })
const enable = dialog.getByRole('button', { name: '启用完全权限' })
expect(await enable.isDisabled()).toBe(true)
// The modal is in this page's body (not a native/new window) and escapes
@ -69,7 +69,7 @@ describe('web e2e: Full access confirmation', () => {
expect(await enable.isEnabled()).toBe(true)
await enable.click()
await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 })
.toBe('访问模式,当前:Full access')
.toBe('访问模式,当前:完全权限')
expect(await dialog.count()).toBe(0)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)

View file

@ -1,10 +1,10 @@
- dialog "确认启用 Full access?":
- heading "确认启用 Full access?" [level=2]
- dialog "确认启用完全权限?":
- heading "确认启用完全权限?" [level=2]
- button "关闭":
- img
- img
- paragraph: 启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。
- paragraph: 启用完全权限后,智能体将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。
- checkbox "我已了解风险,并愿意继续"
- text: 我已了解风险,并愿意继续
- button "取消"
- button "启用 Full access" [disabled]
- button "启用完全权限" [disabled]

View file

@ -18,8 +18,8 @@
- img
- text: 关闭
- text: 权限 选择新会话的默认权限模式
- button "Workspace Write":
- text: Workspace Write
- button "可写入工作区":
- text: 可写入工作区
- img
- text: 语言
- button "中文":

View file

@ -62,7 +62,7 @@ describe('web e2e: settings modal and General preferences', () => {
expect(await trigger.getAttribute('aria-expanded')).toBe('true')
// General is active by default; Permission, Language and Appearance are functional.
expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBe('true')
await dialog.getByRole('button', { name: 'Workspace Write' }).waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: '可写入工作区' }).waitFor({ timeout: 10_000 })
await expect.poll(() => dialog.getByText('语言', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
await expect.poll(() => dialog.getByText('外观', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
const openDocument = dialog.getByRole('button', { name: '打开配置文件' })
@ -150,12 +150,12 @@ describe('web e2e: settings modal and General preferences', () => {
await page.getByRole('button', { name: '设置', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '设置' })
await dialog.waitFor({ timeout: 10_000 })
const selector = dialog.getByRole('button', { name: 'Workspace Write' })
const selector = dialog.getByRole('button', { name: '可写入工作区' })
await selector.waitFor({ timeout: 10_000 })
await expect.poll(() => selector.isEnabled(), { timeout: 5_000 }).toBe(true)
await selector.click()
await page.getByRole('menuitem', { name: 'Read Only' }).click()
await dialog.getByRole('button', { name: 'Read Only' }).waitFor({ timeout: 10_000 })
await page.getByRole('menuitem', { name: '仅可查看' }).click()
await dialog.getByRole('button', { name: '仅可查看' }).waitFor({ timeout: 10_000 })
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
expect(document).toContain('permission:')
@ -170,14 +170,14 @@ describe('web e2e: settings modal and General preferences', () => {
['approval/policy', { policy: 'ask' }],
])
await dialog.getByRole('button', { name: 'Read Only' }).click()
await page.getByRole('menuitem', { name: 'Full access' }).click()
const confirmation = page.getByRole('dialog', { name: '确认启用 Full access?' })
const enable = confirmation.getByRole('button', { name: '启用 Full access' })
await dialog.getByRole('button', { name: '仅可查看' }).click()
await page.getByRole('menuitem', { name: '完全权限' }).click()
const confirmation = page.getByRole('dialog', { name: '确认启用完全权限?' })
const enable = confirmation.getByRole('button', { name: '启用完全权限' })
expect(await enable.isDisabled()).toBe(true)
await confirmation.getByRole('checkbox').click()
await enable.click()
await dialog.getByRole('button', { name: 'Full access' }).waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: '完全权限' }).waitFor({ timeout: 10_000 })
const confirmedDocument = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
expect(confirmedDocument).toContain('defaultPreset: danger-full-access')
const confirmed = scaffold.ctx.sessions.create(SessionId('settings-permission-confirmed'))

View file

@ -56,12 +56,14 @@ export const zh = {
'settings.enter.description': '仅在智能体运行时生效;Cmd/Ctrl+Enter 使用另一行为',
'settings.enter.queue': '排队发送',
'settings.enter.steer': '插话发送',
'access.confirm.title': '确认启用 Full access?',
'access.confirm.description': '启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。',
'access.preset.readOnly': '仅可查看',
'access.preset.workspaceWrite': '可写入工作区',
'access.preset.fullAccess': '完全权限',
'access.confirm.title': '确认启用完全权限?',
'access.confirm.description': '启用完全权限后,智能体将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。',
'access.confirm.acknowledge': '我已了解风险,并愿意继续',
'access.confirm.cancel': '取消',
'access.confirm.enable': '启用 Full access',
'access.fullLabel': 'Full access',
'access.confirm.enable': '启用完全权限',
'hero.headline': '探索未至之境',
'hero.preview': '预览版',
'hero.chooseWorkspace': '选择工作区',
@ -204,12 +206,14 @@ export const en = {
'settings.enter.description': 'Busy only; Cmd/Ctrl+Enter uses the other behavior',
'settings.enter.queue': 'Queue',
'settings.enter.steer': 'Steer',
'access.preset.readOnly': 'Read Only',
'access.preset.workspaceWrite': 'Workspace Write',
'access.preset.fullAccess': 'Full access',
'access.confirm.title': 'Enable Full access?',
'access.confirm.description': 'Full access reduces confirmation steps and lets the agent perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust the current task.',
'access.confirm.acknowledge': 'I understand the risks and want to continue',
'access.confirm.cancel': 'Cancel',
'access.confirm.enable': 'Enable Full access',
'access.fullLabel': 'Full access',
'hero.headline': 'Into the Unknown',
'hero.preview': 'Preview',
'hero.chooseWorkspace': 'Choose workspace',

View file

@ -5,6 +5,7 @@ import type { PermissionSelect as PermissionSelectValue } from '@deepseek-ai/dsh
import { IconChevronDownOutline14, Menu, RiskConfirmation } from '@deepseek-ai/dsh-client-ui-primitives'
import type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ComposerBarProps } from '../contract/slots.ts'
import { en } from '../locales.ts'
import css from './PermissionSelect.module.css'
const FULL_ACCESS = 'danger-full-access'
@ -15,14 +16,14 @@ const FULL_ACCESS = 'danger-full-access'
const shieldOutline = 'M8.20554 0.899994L14.7901 3.36857V7.01026C14.7901 12 11.0466 14.2103 8.20554 15.3C5.36446 14.2103 1.62012 12 1.62012 7.01026V3.36857L8.20554 0.899994Z'
const permissionGlyphs = {
'read-only': (
const permissionGlyphs = new Map<string, ReactNode>([
['read-only', (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
<path d={shieldOutline} stroke="currentColor" strokeWidth="1.31831" strokeLinejoin="round" />
<path d="M12.1654 5.7552L8.9447 9.41475C8.73044 9.65816 8.53628 9.8804 8.35774 10.0423C8.1713 10.2114 7.94235 10.3717 7.64016 10.4254C7.48207 10.4535 7.32 10.4552 7.16151 10.4294C6.85843 10.3801 6.62728 10.2223 6.43836 10.0559C6.25752 9.89653 6.06037 9.67732 5.84264 9.43705L4.72925 8.20897L5.63557 7.38707L6.74897 8.61594C6.98603 8.87755 7.12974 9.03533 7.24673 9.13839C7.31033 9.19443 7.34485 9.21476 7.35823 9.22122C7.38068 9.22484 7.40352 9.22515 7.42593 9.22122C7.40522 9.22502 7.42893 9.23294 7.53583 9.136C7.65132 9.03126 7.79316 8.87139 8.02643 8.60638L11.2479 4.94763L12.1654 5.7552Z" fill="currentColor" />
</svg>
),
'workspace-write': (
)],
['workspace-write', (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
<path d="M8.08887 0.251709C8.20479 0.23085 8.32486 0.241168 8.43652 0.282959L15.0215 2.75171C15.2787 2.84819 15.4492 3.09414 15.4492 3.3689V7.0105C15.4492 7.10986 15.4441 7.2081 15.4414 7.30542C15.0285 7.07175 14.5905 6.87695 14.1309 6.73022V3.82495L8.20508 1.60327L2.2793 3.82495V7.0105C2.27936 9.7171 3.4745 11.5379 5.02734 12.7947C5.01025 12.9942 5 13.1962 5 13.4001C5.00001 13.7617 5.02722 14.1169 5.08008 14.4636C2.91555 13.0393 0.961014 10.752 0.960938 7.0105V3.3689C0.960938 3.09417 1.13146 2.84821 1.38867 2.75171L7.97461 0.282959L8.08887 0.251709Z" fill="currentColor" />
<path d="M11.3525 5.64688V6.85688H5V5.64688H11.3525Z" fill="currentColor" />
@ -30,38 +31,48 @@ const permissionGlyphs = {
<path d="M14.6647 15.6852H10.0338C10.3878 15.3751 10.7567 15.0517 11.0772 14.7706C11.2531 14.6164 11.4144 14.4746 11.5511 14.3547H14.6647V15.6852Z" fill="currentColor" />
<path d="M8.14852 14.1308L7.33925 15.4976C7.22458 15.6912 7.42245 15.9194 7.63037 15.8333L9.09785 15.2254L15.0399 10.0719L14.0905 8.97733L8.14852 14.1308Z" fill="currentColor" />
</svg>
),
[FULL_ACCESS]: (
)],
[FULL_ACCESS, (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
<path d={shieldOutline} stroke="currentColor" strokeWidth="1.31831" strokeLinejoin="round" />
<path d="M9.10094 4.5V8.75939H7.59888V4.5H9.10094Z" fill="currentColor" />
<path d="M9.10094 9.8114V11.5H7.59888V9.8114H9.10094Z" fill="currentColor" />
</svg>
),
} as Record<string, ReactNode>
)],
])
/** Glyph for a permission option value; host-configured names outside the design set get none. */
function permissionGlyph(value: string): ReactNode | undefined {
return permissionGlyphs[value]
return permissionGlyphs.get(value)
}
/**
* Display transform: kebab-case machine names render as title-case labels
* (`workspace-write` → `Workspace Write`); non-kebab host-configured names
* pass through. Full access intentionally overrides the machine-name
* transform so both permission surfaces use the product label `Full access`;
* the warning body remains locale-aware.
* Display transform: built-in machine names render as locale product labels;
* non-kebab host-configured names pass through.
*/
function displayName(name: string): string {
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name
return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
}
function optionLabel(
option: PermissionSelectValue['options'][number],
const BUILT_IN_PERMISSION_NAMES = new Map<string, string>([
['read-only', en['access.preset.readOnly']],
['workspace-write', en['access.preset.workspaceWrite']],
[FULL_ACCESS, en['access.preset.fullAccess']],
])
function permissionLabel(
value: string,
name: string,
t: ComposerBarProps['t'],
): string {
return option.value === FULL_ACCESS ? t('access.fullLabel') : displayName(option.name)
const builtInName = BUILT_IN_PERMISSION_NAMES.get(value)
if (builtInName !== undefined && (name === value || name === builtInName)) {
if (value === 'read-only') return t('access.preset.readOnly')
if (value === 'workspace-write') return t('access.preset.workspaceWrite')
if (value === FULL_ACCESS) return t('access.preset.fullAccess')
}
return displayName(name)
}
export interface PermissionSelectProps {
@ -89,13 +100,20 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect
const currentValue = pick ?? value.currentValue
const current = value.options.find(option => option.value === currentValue)
const currentLabel = current === undefined
? permissionLabel(currentValue, currentValue, t)
: permissionLabel(current.value, current.name, t)
const busy = pick !== null || confirmation !== null
const items: MenuEntry[] = value.options
.filter(o => o.value !== 'custom')
.map((option) => {
const icon = permissionGlyph(option.value)
return { id: option.value, label: optionLabel(option, t), ...icon === undefined ? {} : { icon } }
return {
id: option.value,
label: permissionLabel(option.value, option.name, t),
...icon === undefined ? {} : { icon },
}
})
const submit = (id: string): void => {
@ -141,7 +159,7 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect
<button
type="button"
className={css.trigger}
aria-label={t('input.accessMode', { name: current === undefined ? displayName(currentValue) : optionLabel(current, t) })}
aria-label={t('input.accessMode', { name: currentLabel })}
title={current?.description}
disabled={locked || busy}
onClick={() => { setOpen(!open) }}
@ -149,7 +167,7 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect
{permissionGlyph(currentValue) !== undefined && (
<span className={css.triggerIcon} aria-hidden>{permissionGlyph(currentValue)}</span>
)}
<span className={css.triggerLabel}>{current === undefined ? displayName(currentValue) : optionLabel(current, t)}</span>
<span className={css.triggerLabel}>{currentLabel}</span>
<span className={clsx(css.chevron, open && css.chevronOpen)} aria-hidden>
<IconChevronDownOutline14 />
</span>

View file

@ -1330,24 +1330,43 @@ describe('command launcher chrome and control seats', () => {
}
const { view } = bench({ permissions, command })
const trigger = view.getByLabelText(/^访问模式/) as HTMLButtonElement
// Title-case display is presentation only; the menu ids stay machine names.
expect(trigger.textContent).toBe('Read Only')
// Product-label display is presentation only; the menu ids stay machine names.
expect(trigger.textContent).toBe('仅可查看')
expect([...trigger.querySelectorAll('svg')]
.every(icon => icon.closest('[aria-hidden="true"]') !== null)).toBe(true)
fireEvent.click(trigger)
const items = view.getAllByRole('menuitem')
expect(items.map(o => o.textContent)).toEqual(['Read Only', 'Workspace Write', 'Full access'])
expect(items.map(o => o.textContent)).toEqual(['仅可查看', '可写入工作区', '完全权限'])
fireEvent.click(items[1]!)
// Optimistic pick + disable until admission resolves (command stub resolves true).
const busy = view.getByLabelText(/^访问模式/) as HTMLButtonElement
expect(busy.textContent).toBe('Workspace Write')
expect(busy.textContent).toBe('可写入工作区')
expect(busy.disabled).toBe(true)
expect(command).toHaveBeenCalledWith('/permission workspace-write')
await act(async () => {})
expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(false)
})
it('requires explicit risk acknowledgement before submitting Full access', async () => {
it('the Access chip preserves host labels for built-in preset values', () => {
const permissions = {
options: [
{ value: 'read-only', name: 'Review Only' },
{ value: 'workspace-write', name: 'Project Files' },
{ value: 'danger-full-access', name: 'Operator Mode' },
{ value: 'custom-mode', name: 'custom-mode' },
{ value: '__proto__', name: '__proto__' },
],
currentValue: 'workspace-write',
}
const { view } = bench({ permissions })
const trigger = view.getByLabelText(/^访问模式/) as HTMLButtonElement
expect(trigger.textContent).toBe('Project Files')
fireEvent.click(trigger)
expect(view.getAllByRole('menuitem').map(item => item.textContent))
.toEqual(['Review Only', 'Project Files', 'Operator Mode', 'Custom Mode', '__proto__'])
})
it('requires explicit risk acknowledgement before submitting full access', async () => {
const command = vi.fn(() => Promise.resolve(true))
const permissions = {
options: [
@ -1358,11 +1377,11 @@ describe('command launcher chrome and control seats', () => {
}
const { view } = bench({ permissions, command })
fireEvent.click(view.getByLabelText(/^访问模式/))
fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
fireEvent.click(view.getByRole('menuitem', { name: '完全权限' }))
expect(command).not.toHaveBeenCalled()
expect(view.getByRole('dialog', { name: '确认启用 Full access?' })).toBeTruthy()
const enable = view.getByRole('button', { name: '启用 Full access' }) as HTMLButtonElement
expect(view.getByRole('dialog', { name: '确认启用完全权限?' })).toBeTruthy()
const enable = view.getByRole('button', { name: '启用完全权限' }) as HTMLButtonElement
expect(enable.disabled).toBe(true)
fireEvent.click(view.getByRole('checkbox', { name: '我已了解风险,并愿意继续' }))
@ -1372,11 +1391,11 @@ describe('command launcher chrome and control seats', () => {
expect(command).toHaveBeenCalledOnce()
expect(command).toHaveBeenCalledWith('/permission danger-full-access')
expect(view.queryByRole('dialog')).toBeNull()
expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).textContent).toBe('Full access')
expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).textContent).toBe('完全权限')
await act(async () => {})
})
it('cancels a Full access selection without changing permission and resets acknowledgement', () => {
it('cancels a full access selection without changing permission and resets acknowledgement', () => {
const command = vi.fn(() => Promise.resolve(true))
const permissions = {
options: [
@ -1388,21 +1407,21 @@ describe('command launcher chrome and control seats', () => {
const { view } = bench({ permissions, command })
const openConfirmation = () => {
fireEvent.click(view.getByLabelText(/^访问模式/))
fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
fireEvent.click(view.getByRole('menuitem', { name: '完全权限' }))
}
openConfirmation()
fireEvent.click(view.getByRole('checkbox'))
fireEvent.click(view.getByRole('button', { name: '取消' }))
expect(command).not.toHaveBeenCalled()
expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).textContent).toBe('Workspace Write')
expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).textContent).toBe('可写入工作区')
openConfirmation()
expect((view.getByRole('checkbox') as HTMLInputElement).checked).toBe(false)
expect((view.getByRole('button', { name: '启用 Full access' }) as HTMLButtonElement).disabled).toBe(true)
expect((view.getByRole('button', { name: '启用完全权限' }) as HTMLButtonElement).disabled).toBe(true)
})
it('revokes an open Full access confirmation when the task locks', () => {
it('revokes an open full access confirmation when the task locks', () => {
const command = vi.fn(() => Promise.resolve(true))
const permissions = {
options: [
@ -1413,14 +1432,14 @@ describe('command launcher chrome and control seats', () => {
}
const { view, session } = bench({ permissions, command })
fireEvent.click(view.getByLabelText(/^访问模式/))
fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
fireEvent.click(view.getByRole('menuitem', { name: '完全权限' }))
fireEvent.click(view.getByRole('checkbox'))
act(() => { session.set(snapshotOf({ removed: true })) })
expect(view.queryByRole('dialog')).toBeNull()
expect(command).not.toHaveBeenCalled()
})
it('resets an open Full access confirmation when switching tasks', () => {
it('resets an open full access confirmation when switching tasks', () => {
const command = vi.fn(() => Promise.resolve(true))
const permissions = {
options: [
@ -1431,7 +1450,7 @@ describe('command launcher chrome and control seats', () => {
}
const { view, props } = bench({ permissions, command })
fireEvent.click(view.getByLabelText(/^访问模式/))
fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
fireEvent.click(view.getByRole('menuitem', { name: '完全权限' }))
fireEvent.click(view.getByRole('checkbox'))
view.rerender(<InputBar {...props} sessionId={'s2' as SessionId} />)
expect(view.queryByRole('dialog')).toBeNull()

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 packages/client/ui-permission-presets/README.md
README.md: fc7328bd0029ea86b369d3457c43aa4e24db1243
README.zh.md: 523ea66a0f7fa3eeddb0f8fe1a901d03f9720c3d
README.md: 6a82ebe45edb7e6055bf4c69a2e90bf21255696c
README.zh.md: bd5588b13cc55d856163ff3eadf04f8059b2f7a9

View file

@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
This package provides permission preset surfaces for two lifetimes in the Web GUI: a General-settings row chooses the default for later sessions without switching the current session. A picker on the host `/permission` command switches the current session through one flat preset list with the active value marked. Kebab-case names render as title-case labels, and `danger-full-access` is presented as `Full access`. Choosing full access requires an explicit risk acknowledgement before either surface writes it. Both surfaces read one host-computed projection and write through one path, so the pushed projection frame is the single confirmation both follow.
This package provides permission preset surfaces for two lifetimes in the Web GUI: a General-settings row chooses the default for later sessions without switching the current session. A picker on the host `/permission` command switches the current session through one flat preset list with the active value marked. Canonical built-in names render as locale-owned product labels, explicit host labels remain unchanged, and unknown kebab-case names render in title case. Choosing full access requires an explicit risk acknowledgement before either surface writes it. Both surfaces read one host-computed projection and write through one path, so the pushed projection frame is the single confirmation both follow.
## Table of Contents
@ -29,11 +29,11 @@ Mount this plugin alongside the settings and commands packages; the permission r
### The picker
A pick submits the `/permission <preset>` command line. The argued path (`/permission <preset>` typed directly) still switches directly; the decoration replaces only the bare invocation. Unknown kebab-case preset names render in title case, and `custom` is display state, never a target.
A pick submits the `/permission <preset>` command line. The argued path (`/permission <preset>` typed directly) still switches directly; the decoration replaces only the bare invocation. The built-in labels are `Read Only`, `Workspace Write`, and `Full access` in English and `仅可查看`, `可写入工作区`, and `完全权限` in Chinese; `custom` is display state, never a target.
### The Settings row
The row derives its options from the host's dynamic `defaultPreset` enum and writes one settings mutation. The value applies only when a later session is created; changing it never switches or rewrites the current session.
The row derives its options from the host's dynamic `defaultPreset` enum, uses the same localized labels as the current-session picker, and writes one settings mutation. The value applies only when a later session is created; changing it never switches or rewrites the current session.
-----

View file

@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
本包为 Web GUI 中两种生命周期提供权限预设表面:通用设置中的一行选择之后创建会话所用的默认值,但不会切换当前会话。挂在宿主 `/permission` 命令上的选择器通过一张扁平预设列表切换当前会话,并标记 active 值。Kebab-case 名称渲染为 Title Case 标签,`danger-full-access` 显示为 `Full access`。选择完全权限时,该行或选择器写入前必须先显式确认风险。两个表面读取同一份宿主计算的投影、经同一条路径写入,因此推送的投影帧是两者共同跟随的唯一确认。
本包为 Web GUI 中两种生命周期提供权限预设表面:通用设置中的一行选择之后创建会话所用的默认值,但不会切换当前会话。挂在宿主 `/permission` 命令上的选择器通过一张扁平预设列表切换当前会话,并标记 active 值。规范内置名称渲染为 locale 所有的产品标签,显式 host 标签保持原样,未知 kebab-case 名称渲染为 Title Case。选择完全权限时,该行或选择器写入前必须先显式确认风险。两个表面读取同一份宿主计算的投影、经同一条路径写入,因此推送的投影帧是两者共同跟随的唯一确认。
## 目录
@ -29,11 +29,11 @@ kind: "package-reference"
### 选择器
选中即提交 `/permission <preset>` 命令行。带参路径(直接键入 `/permission <preset>`)仍直接切换;装饰只替换裸调用。未知 kebab-case 预设名渲染为 Title Case 标签,`custom` 只是显示状态,绝非目标。
选中即提交 `/permission <preset>` 命令行。带参路径(直接键入 `/permission <preset>`)仍直接切换;装饰只替换裸调用。内置标签在英文界面中是 `Read Only`、`Workspace Write` 和 `Full access`,在中文界面中是「仅可查看」「可写入工作区」和「完全权限」;`custom` 只是显示状态,绝非目标。
### 设置行
该行从宿主动态的 `defaultPreset` enum 推导选项,写入一条设置变更操作。该值只在之后创建会话时生效;改变它绝不会切换或改写当前会话。
该行从宿主动态的 `defaultPreset` enum 推导选项,使用与当前会话选择器相同的本地化标签,并写入一条设置变更操作。该值只在之后创建会话时生效;改变它绝不会切换或改写当前会话。
-----

View file

@ -12,7 +12,7 @@ import {
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PermissionSettingsState } from './settings-store.ts'
import type { PermissionSettingsKey } from './locales.ts'
import { FULL_ACCESS_PRESET } from './presentation.ts'
import { displayPermissionPreset, FULL_ACCESS_PRESET } from './presentation.ts'
import css from './PermissionRow.module.css'
/** Registration-side business face for the host-backed preference. */
@ -58,8 +58,9 @@ export function PermissionRow({ load, select, usePermission, t }: PermissionRowP
if (state.status === 'unavailable') return null
const selected = state.options.find(option => option.id === state.currentValue)
const busy = state.status === 'loading' || state.status === 'saving' || confirmingFullAccess
const label = selected?.label
?? (busy ? t('loading') : t('unavailable'))
const optionLabel = (option: PermissionSettingsState['options'][number]): string =>
displayPermissionPreset(option.id, option.label, t)
const label = selected !== undefined ? optionLabel(selected) : (busy ? t('loading') : t('unavailable'))
const description: string = state.error ?? t('description')
return (
@ -72,7 +73,7 @@ export function PermissionRow({ load, select, usePermission, t }: PermissionRowP
<Menu
open={open}
onClose={() => { setOpen(false) }}
items={state.options.map(option => ({ id: option.id, label: option.label }))}
items={state.options.map(option => ({ id: option.id, label: optionLabel(option) }))}
selectedId={state.currentValue}
onSelect={(id) => {
setOpen(false)

View file

@ -61,7 +61,7 @@ function optionsOf(value: PermissionSelect, t: (key: string) => string): SelectO
.filter(option => option.value !== 'custom')
.map(option => ({
id: option.value,
label: displayPermissionPreset(option.value, option.name),
label: displayPermissionPreset(option.value, option.name, t),
...(option.description !== undefined ? { detail: option.description } : {}),
...(option.value === value.currentValue ? { active: true } : {}),
...(option.value === FULL_ACCESS_PRESET
@ -92,6 +92,9 @@ export function apply(ctx: ClientContext): void {
ctx.effect(() => {
const disposers = [
ctx.locale.register(ACCESS_NS, 'zh', {
'preset.readOnly': accessZh['preset.readOnly'],
'preset.workspaceWrite': accessZh['preset.workspaceWrite'],
'preset.fullAccess': accessZh['preset.fullAccess'],
'confirm.title': accessZh['confirm.title'],
'confirm.description': accessZh['confirm.description'],
'confirm.acknowledge': accessZh['confirm.acknowledge'],
@ -99,6 +102,9 @@ export function apply(ctx: ClientContext): void {
'confirm.enable': accessZh['confirm.enable'],
}),
ctx.locale.register(ACCESS_NS, 'en', {
'preset.readOnly': accessEn['preset.readOnly'],
'preset.workspaceWrite': accessEn['preset.workspaceWrite'],
'preset.fullAccess': accessEn['preset.fullAccess'],
'confirm.title': accessEn['confirm.title'],
'confirm.description': accessEn['confirm.description'],
'confirm.acknowledge': accessEn['confirm.acknowledge'],

View file

@ -6,11 +6,14 @@ export const zh = {
'description': '选择新会话的默认权限模式',
'loading': '加载中',
'unavailable': '不可用',
'confirm.title': '确认启用 Full access?',
'confirm.description': '启用 Full access 后,新会话将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任后续任务时使用。',
'preset.readOnly': '仅可查看',
'preset.workspaceWrite': '可写入工作区',
'preset.fullAccess': '完全权限',
'confirm.title': '确认启用完全权限?',
'confirm.description': '启用完全权限后,新会话将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任后续任务时使用。',
'confirm.acknowledge': '我已了解风险,并愿意继续',
'confirm.cancel': '取消',
'confirm.enable': '启用 Full access',
'confirm.enable': '启用完全权限',
} satisfies Record<string, string>
/** The settings.permission namespace key union. */
@ -22,6 +25,9 @@ export const en = {
'description': 'Choose the default permission mode for new sessions',
'loading': 'Loading',
'unavailable': 'Unavailable',
'preset.readOnly': 'Read Only',
'preset.workspaceWrite': 'Workspace Write',
'preset.fullAccess': 'Full access',
'confirm.title': 'Enable Full access?',
'confirm.description': 'Full access lets new sessions reduce confirmation steps and perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust subsequent tasks.',
'confirm.acknowledge': 'I understand the risks and want to continue',
@ -31,11 +37,14 @@ export const en = {
/** Simplified Chinese dictionary for the current-session popup gate. */
export const accessZh = {
'confirm.title': '确认启用 Full access?',
'confirm.description': '启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。',
'preset.readOnly': '仅可查看',
'preset.workspaceWrite': '可写入工作区',
'preset.fullAccess': '完全权限',
'confirm.title': '确认启用完全权限?',
'confirm.description': '启用完全权限后,智能体将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。',
'confirm.acknowledge': '我已了解风险,并愿意继续',
'confirm.cancel': '取消',
'confirm.enable': '启用 Full access',
'confirm.enable': '启用完全权限',
} satisfies Record<string, string>
/** Current-session popup-gate key union. */
@ -43,6 +52,9 @@ export type PermissionAccessKey = keyof typeof accessZh
/** English dictionary for the current-session popup gate. */
export const accessEn = {
'preset.readOnly': 'Read Only',
'preset.workspaceWrite': 'Workspace Write',
'preset.fullAccess': 'Full access',
'confirm.title': 'Enable Full access?',
'confirm.description': 'Full access reduces confirmation steps and lets the agent perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust the current task.',
'confirm.acknowledge': 'I understand the risks and want to continue',

View file

@ -1,6 +1,26 @@
import { en } from './locales.ts'
/** Machine value of the preset that requires an explicit GUI risk gate. */
export const FULL_ACCESS_PRESET = 'danger-full-access'
/** Locale dictionary key for a built-in permission preset label. */
export type PermissionPresetLabelKey =
| 'preset.readOnly'
| 'preset.workspaceWrite'
| 'preset.fullAccess'
const PRESET_LABEL_KEYS = new Map<string, PermissionPresetLabelKey>([
['read-only', 'preset.readOnly'],
['workspace-write', 'preset.workspaceWrite'],
[FULL_ACCESS_PRESET, 'preset.fullAccess'],
])
const DEFAULT_PRESET_LABELS: Record<PermissionPresetLabelKey, string> = {
'preset.readOnly': en['preset.readOnly'],
'preset.workspaceWrite': en['preset.workspaceWrite'],
'preset.fullAccess': en['preset.fullAccess'],
}
/**
* Convert conventional kebab-case preset names into user-facing title case.
* @param name - host-supplied preset label or key.
@ -15,8 +35,17 @@ export function displayPresetName(name: string): string {
* Render a permission preset under its product label.
* @param value - preset machine value.
* @param name - host-supplied preset name.
* @returns the Full access product label or the conventional display name.
* @param t - optional locale dictionary lookup for built-in product labels.
* @returns the built-in product label or the conventional display name.
*/
export function displayPermissionPreset(value: string, name: string): string {
return value === FULL_ACCESS_PRESET ? 'Full access' : displayPresetName(name)
export function displayPermissionPreset(
value: string,
name: string,
t?: (key: PermissionPresetLabelKey) => string,
): string {
const key = PRESET_LABEL_KEYS.get(value)
if (key !== undefined && (name === value || name === DEFAULT_PRESET_LABELS[key])) {
return t?.(key) ?? DEFAULT_PRESET_LABELS[key]
}
return displayPresetName(name)
}

View file

@ -21,7 +21,7 @@ import {
PermissionRow, type PermissionRowInjected,
} from '../src/client/PermissionRow.tsx'
import { apply, inject } from '../src/client/index.ts'
import { accessEn } from '../src/client/locales.ts'
import { accessEn, accessZh } from '../src/client/locales.ts'
const sid = (k: string): SessionId => k as SessionId
@ -79,7 +79,7 @@ async function bench() {
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
return {
ctx, fiber, values, commands, remote,
ctx, fiber, locale, values, commands, remote,
setResult: (r: { ok: boolean; matched?: boolean }) => { commandResult = r },
decoration: () => decoration,
permissionRow: () => ctx.slots.entries('settings.general.item')
@ -117,7 +117,7 @@ describe('ui-permission browser plugin', () => {
const again = await c.ui.options(proj, new AbortController().signal)
expect(again.find(option => option.id === 'workspace-write')?.active).toBe(true)
expect(again.find(option => option.id === 'read-only')?.detail).toBe('Reads only.')
// Kebab-case names title-case; non-kebab host-configured names pass through.
// English built-ins use product labels; other kebab-case names title-case.
expect(again.map(option => option.label)).toEqual(['Read Only', 'Workspace Write', 'Full access'])
expect(again.find(option => option.id === 'danger-full-access')?.confirmation).toEqual({
title: 'Enable Full access?',
@ -126,9 +126,27 @@ describe('ui-permission browser plugin', () => {
cancelLabel: 'Cancel',
confirmLabel: 'Enable Full access',
})
b.values.set(sid('s1'), { ...SELECT, options: [{ value: 'plain', name: 'Ask Every Time' }] })
b.locale.setLocale('zh')
const localized = await c.ui.options(proj, new AbortController().signal)
expect(localized.map(option => option.label)).toEqual(['仅可查看', '可写入工作区', '完全权限'])
expect(localized.find(option => option.id === 'danger-full-access')?.confirmation).toEqual({
title: '确认启用完全权限?',
description: accessZh['confirm.description'],
acknowledgeLabel: '我已了解风险,并愿意继续',
cancelLabel: '取消',
confirmLabel: '启用完全权限',
})
b.values.set(sid('s1'), { ...SELECT, options: [
{ value: 'workspace-write', name: 'Project Files' },
{ value: 'danger-full-access', name: 'Operator Mode' },
{ value: 'custom-mode', name: 'custom-mode' },
{ value: '__proto__', name: '__proto__' },
{ value: 'plain', name: 'Ask Every Time' },
] })
const passthrough = await c.ui.options(proj, new AbortController().signal)
expect(passthrough[0]?.label).toBe('Ask Every Time')
expect(passthrough.map(option => option.label)).toEqual([
'Project Files', 'Operator Mode', 'Custom Mode', '__proto__', 'Ask Every Time',
])
// A projection that vanished between availability and open throws.
expect(() => c.ui.options({ sessionId: sid('ghost') }, new AbortController().signal))
.toThrow(/not available on this host/)

View file

@ -6,7 +6,7 @@ import { bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-
import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/client/schema.ts'
import { PermissionRow, type PermissionRowProps } from '../src/client/PermissionRow.tsx'
import { en } from '../src/client/locales.ts'
import { zh } from '../src/client/locales.ts'
import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
import { PermissionPresetSettingsController } from '../src/client/settings-store.ts'
@ -48,7 +48,7 @@ function ok<T>(value: T) {
return { ok: true as const, value }
}
const dictionary: Record<string, string> = en
const dictionary: Record<string, string> = zh
const t: PermissionRowProps['t'] = key => dictionary[key] ?? key
type AttentionSnapshot = Parameters<Parameters<PermissionRowProps['useSessionPendingInteraction']>[0]>[0]
const noAttention: AttentionSnapshot = new Map()
@ -81,7 +81,7 @@ describe('PermissionRow', () => {
},
})
mount(controller)
const button = await screen.findByRole('button', { name: 'Read Only' })
const button = await screen.findByRole('button', { name: '仅可查看' })
expect(button.getAttribute('aria-expanded')).toBe('false')
fireEvent.click(button)
expect(button.getAttribute('aria-expanded')).toBe('true')
@ -91,15 +91,15 @@ describe('PermissionRow', () => {
fireEvent.click(button)
expect(button.getAttribute('aria-expanded')).toBe('false')
fireEvent.click(button)
fireEvent.click(screen.getByRole('menuitem', { name: 'Read Only' }))
fireEvent.click(screen.getByRole('menuitem', { name: '仅可查看' }))
expect(mutate).not.toHaveBeenCalled()
fireEvent.click(button)
fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace Write' }))
await screen.findByRole('button', { name: 'Workspace Write' })
fireEvent.click(screen.getByRole('menuitem', { name: '可写入工作区' }))
await screen.findByRole('button', { name: '可写入工作区' })
expect(mutate).toHaveBeenCalledOnce()
})
it('requires explicit acknowledgement before saving Full access', async () => {
it('requires explicit acknowledgement before saving full access', async () => {
const mutate = vi.fn(() => Promise.resolve(ok(view('danger-full-access', 1))))
const controller = derivedController({
settings: {
@ -108,15 +108,15 @@ describe('PermissionRow', () => {
},
})
mount(controller)
fireEvent.click(await screen.findByRole('button', { name: 'Read Only' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Full access' }))
fireEvent.click(await screen.findByRole('button', { name: '仅可查看' }))
fireEvent.click(screen.getByRole('menuitem', { name: '完全权限' }))
expect(mutate).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(screen.queryByRole('dialog', { name: 'Enable Full access?' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Read Only' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Full access' }))
const dialog = screen.getByRole('dialog', { name: 'Enable Full access?' })
const enable = screen.getByRole('button', { name: 'Enable Full access' })
fireEvent.click(screen.getByRole('button', { name: '取消' }))
expect(screen.queryByRole('dialog', { name: '确认启用完全权限?' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: '仅可查看' }))
fireEvent.click(screen.getByRole('menuitem', { name: '完全权限' }))
const dialog = screen.getByRole('dialog', { name: '确认启用完全权限?' })
const enable = screen.getByRole('button', { name: '启用完全权限' })
expect((enable as HTMLButtonElement).disabled).toBe(true)
fireEvent.click(screen.getByRole('checkbox'))
fireEvent.click(enable)
@ -142,7 +142,7 @@ describe('PermissionRow', () => {
},
})
mount(readonly)
expect((await screen.findByRole('button', { name: 'Read Only' })).hasAttribute('disabled')).toBe(true)
expect((await screen.findByRole('button', { name: '仅可查看' })).hasAttribute('disabled')).toBe(true)
})
it('shows loading and a contained write error', async () => {
@ -162,11 +162,11 @@ describe('PermissionRow', () => {
},
})
mount(controller)
expect((await screen.findByRole('button', { name: 'Loading' })).hasAttribute('disabled')).toBe(true)
expect((await screen.findByRole('button', { name: '加载中' })).hasAttribute('disabled')).toBe(true)
describe.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] }))
const button = await screen.findByRole('button', { name: 'Read Only' })
const button = await screen.findByRole('button', { name: '仅可查看' })
fireEvent.click(button)
fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace Write' }))
fireEvent.click(screen.getByRole('menuitem', { name: '可写入工作区' }))
expect((await screen.findByRole('alert')).textContent).toBe('changed elsewhere')
})
})