Compare commits

..
Author SHA1 Message Date
weichenandCursor 1edd872c55 Prefer progress cards over duplicate CurrentTaskCard
Why: When a slow-lane progress card is active, hide the plan/todos
CurrentTaskCard so the work column has one authoritative task object.
Evidence: npx vitest run (workbench-progress + full suite)
Impact: Workbench work tab; tidyCards skips done+danger failures.
Refs: fast-slow-channel-display-u1-plan W4

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 07:45:08 +00:00
weichen a45a4d8652 Merge pull request 'Fix password form semantics and typed setting inputs' (#66) from feature/rdm-audit-capability-gate-changepw-form into main
Reviewed-on: https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Frontend/pulls/66
2026-10-12 06:20:05 +00:00
weichen 378ec24bbd Merge pull request 'Gate the audit workspace entry on rdm.inspect command availability' (#65) from feature/rdm-audit-capability-gate into main
Reviewed-on: https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Frontend/pulls/65
2026-10-12 06:19:56 +00:00
weichen cc233fec90 Assert readonly via attribute in the changepw spec
Why: the repo's @playwright/test version has no toBeReadOnly matcher.

Evidence: failing run shows TypeError at the matcher; the attribute
assertion works across versions.

Impact: none beyond the spec assertion.

Refs: 2026-10-12 diagnostic report, increment 3.
2026-10-12 03:50:52 +00:00
weichen 8db7093a9d Fix password form semantics and per-type setting inputs
Why: the change-password modal rendered loose password inputs outside any
<form> with no autocomplete or username association (browser password-form
warnings; Enter did not submit), and its 401 handling collapsed every
cause into "wrong current password". SettingInput masked ALL free-text
config values as password fields.

Evidence: vitest 24 files / 116 tests incl. new settingInputAttrs matrix;
knip and tsc -b && vite build pass; e2e changepw-form spec (driven by the
dev-repo e2e.sh plain phase) covers form submit via Enter, autocomplete
trio, readonly username and the classified error copy.

Impact: ChangePasswordModal uses form onSubmit, htmlFor/id association,
username/current-password/new-password autocomplete and reuses
humanChangePasswordError; SettingInput renders text inputs unless the
field is secret.

Refs: 2026-10-12 diagnostic report, increment 3.
2026-10-12 03:48:13 +00:00
7 changed files with 149 additions and 14 deletions
+57
View File
@@ -0,0 +1,57 @@
// 增量 3 验收(2026-10-12 诊断报告):改密弹窗表单语义——<form onSubmit> 承载提交
//(Enter 可提交)、autocomplete 三段标注(username/current-password/new-password)、
// 只读用户名关联、错误文案复用 humanChangePasswordError(401 不再一刀切)。
// 驱动:scripts/e2e.sh 第 4 阶段(E2E_PLAIN_BASE=1)——表单语义与命令面无关,
// 任意 auth-on 服务可验,普通基座段顺路覆盖;RDM 组合主跑(第 3 阶段)本文件自跳过。
import { test, expect, type Page } from '@playwright/test'
const OWNER = process.env.E2E_OWNER || 'e2e-auditor'
const PASSWORD = process.env.E2E_PASSWORD || 'E2eAudit#2026'
const PASSWORD2 = process.env.E2E_PASSWORD2 || 'E2eAudit#2026b'
test.skip(!(process.env.PLAYWRIGHT_BASE_URL && process.env.E2E_PLAIN_BASE),
'set PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE (scripts/e2e.sh phase 4 drives this suite)')
async function login(page: Page) {
await page.goto('/')
await page.getByPlaceholder('由管理员分配').fill(OWNER)
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
await page.getByRole('button', { name: '登录', exact: true }).click()
const gate = page.getByText('修改密码')
const gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
if (gated) {
const pwInputs = page.locator('.lg-card input[type="password"]')
await pwInputs.nth(0).fill(PASSWORD)
await pwInputs.nth(1).fill(PASSWORD2)
await pwInputs.nth(2).fill(PASSWORD2)
await page.getByRole('button', { name: '设置新密码并进入' }).click()
} else {
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2)
await page.getByRole('button', { name: '登录', exact: true }).click()
}
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
}
test('改密弹窗:form 提交、autocomplete/用户名关联、错误分类文案', async ({ page }) => {
await login(page)
await page.locator('.tsb-user').click()
await page.getByRole('button', { name: '修改密码…' }).click()
const dlg = page.getByRole('dialog', { name: '修改密码' })
await expect(dlg).toBeVisible()
// 用户名只读关联 + 三段 autocomplete。
const username = dlg.locator('input[autocomplete="username"]')
await expect(username).toHaveValue(OWNER)
// 本仓 @playwright/test 无 toBeReadOnly:以 readonly 属性断言只读关联。
await expect(username).toHaveAttribute('readonly', '')
await expect(dlg.locator('input[autocomplete="current-password"]')).toHaveCount(1)
await expect(dlg.locator('input[autocomplete="new-password"]')).toHaveCount(2)
// 表单语义:填错当前密码后按 Enter(不点按钮)提交——证明提交走 <form onSubmit>。
await dlg.locator('input[autocomplete="current-password"]').fill('definitely-wrong-pass')
await dlg.locator('input[autocomplete="new-password"]').first().fill('ANewPass#2026x')
await dlg.locator('input[autocomplete="new-password"]').nth(1).fill('ANewPass#2026x')
await dlg.locator('input[autocomplete="current-password"]').press('Enter')
// 后端 401 "current password incorrect" → humanChangePasswordError 的分类文案。
await expect(dlg.getByText('当前密码不正确')).toBeVisible({ timeout: 10_000 })
})
+20 -11
View File
@@ -4,6 +4,7 @@ import { useState } from 'react'
import { api } from '../api'
import { store, useStore } from '../store'
import { Icon } from '@ui'
import { humanChangePasswordError } from './LoginPage'
function Shell({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
return (
@@ -20,7 +21,10 @@ function Shell({ title, onClose, children }: { title: string; onClose: () => voi
)
}
/** 主动改密(账户菜单入口):当前密码 + 新密码 + 确认。 */
/** 主动改密(账户菜单入口):用户名(只读关联)+ 当前密码 + 新密码 + 确认。
* 表单语义(2026-10-12 增量 3):<form onSubmit> 承载提交(Enter 可用)、autocomplete
* 三段标注(username/current-password/new-password)、label 与 input 关联;
* 错误文案复用 humanChangePasswordError——401 不再一刀切成「当前密码不正确」。 */
export function ChangePasswordModal({ onClose }: { onClose: () => void }) {
const me = useStore(s => s.me)
const [current, setCurrent] = useState('')
@@ -41,23 +45,28 @@ export function ChangePasswordModal({ onClose }: { onClose: () => void }) {
store.toast('密码已更新')
setTimeout(onClose, 900)
} catch (e) {
setErr(String(e).includes('401') ? '当前密码不正确' : String(e))
setErr(humanChangePasswordError(String(e)))
} finally { setBusy(false) }
}
return (
<Shell title="修改密码" onClose={onClose}>
{ok ? <div className="ad-notice">密码已更新,其他设备的会话已注销。</div> : (
<div className="ad-changepw">
<label className="lg-label">当前密码({me?.owner ?? ''})</label>
<input className="lg-input" type="password" value={current} onChange={e => setCurrent(e.target.value)} autoFocus />
<label className="lg-label">新密码(≥8 位)</label>
<input className="lg-input" type="password" value={next} onChange={e => setNext(e.target.value)} />
<label className="lg-label">确认新密码</label>
<input className="lg-input" type="password" value={confirm} onChange={e => setConfirm(e.target.value)} />
<form className="ad-changepw" onSubmit={e => { e.preventDefault(); submit() }}>
<label className="lg-label" htmlFor="changepw-username">用户名</label>
<input id="changepw-username" className="lg-input" value={me?.owner ?? ''} readOnly autoComplete="username" />
<label className="lg-label" htmlFor="changepw-current">当前密码</label>
<input id="changepw-current" className="lg-input" type="password" autoComplete="current-password"
value={current} onChange={e => setCurrent(e.target.value)} autoFocus />
<label className="lg-label" htmlFor="changepw-next">新密码(≥8 位)</label>
<input id="changepw-next" className="lg-input" type="password" autoComplete="new-password"
value={next} onChange={e => setNext(e.target.value)} />
<label className="lg-label" htmlFor="changepw-confirm">确认新密码</label>
<input id="changepw-confirm" className="lg-input" type="password" autoComplete="new-password"
value={confirm} onChange={e => setConfirm(e.target.value)} />
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
<button className="lg-submit" disabled={busy || !current || !next || !confirm} onClick={submit}>{busy ? '提交中…' : '更新密码'}</button>
</div>
<button className="lg-submit" type="submit" disabled={busy || !current || !next || !confirm}>{busy ? '提交中…' : '更新密码'}</button>
</form>
)}
</Shell>
)
+12 -1
View File
@@ -112,6 +112,16 @@ export function SettingsModal() {
)
}
// 输入框语义(2026-10-12 增量 3):仅秘密字段用密码框;普通文本用 text——
// 无条件 type="password" 会把整排设置项当凭据表单(浏览器「password 不在 form 中/
// 缺少 username」警告来源之一),也把普通文本无谓遮蔽。
export function settingInputAttrs(desc: SettingDesc): { type: 'text' | 'password'; autoComplete: string } {
const isSecret = desc.secret || desc.type === 'secret'
return isSecret
? { type: 'password', autoComplete: 'new-password' }
: { type: 'text', autoComplete: 'off' }
}
export function SettingInput({ desc, value, onChange }: { desc: SettingDesc; value: any; onChange: (v: any) => void }) {
if (desc.type === 'boolean') {
return <input type="checkbox" checked={value === true || value === 'true'} onChange={e => onChange(e.target.checked)} />
@@ -122,7 +132,8 @@ export function SettingInput({ desc, value, onChange }: { desc: SettingDesc; val
</select>
}
const isSecret = desc.secret || desc.type === 'secret'
return <input type="password" autoComplete="new-password" style={{ width: '100%' }} value={typeof value === 'string' && value === '••••••' ? '' : (value ?? '')}
const attrs = settingInputAttrs(desc)
return <input type={attrs.type} autoComplete={attrs.autoComplete} style={{ width: '100%' }} value={typeof value === 'string' && value === '••••••' ? '' : (value ?? '')}
placeholder={isSecret ? '(已配置,输入以覆盖)' : String(desc.default ?? '')}
onChange={e => onChange(e.target.value)} />
}
+7 -1
View File
@@ -91,6 +91,11 @@ function FilesTab() {
return <div className="tab-body"><ArtifactsPanel /></div>
}
/** U1:同会话存在后台 progress 卡(进行中/待确认)时,progress 为权威对象,隐藏 CurrentTaskCard 避免双标题。 */
export function hasActiveProgressCard(cards: ActivityCard[]): boolean {
return cards.some(c => c.kind === 'progress' && (c.status === 'active' || c.status === 'action_required'))
}
function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
plan: Parameters<typeof CurrentTaskCard>[0]['plan']
todos: Parameters<typeof CurrentTaskCard>[0]['todos']
@@ -99,10 +104,11 @@ function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
projectCards: ActivityCard[]
cards: ActivityCard[]
}) {
const showCurrentTask = !hasActiveProgressCard(cards)
return (
<>
<div className="tab-body" data-scroll-sync="inbox">
<CurrentTaskCard plan={plan} todos={todos} />
{showCurrentTask ? <CurrentTaskCard plan={plan} todos={todos} /> : null}
<div className="inbox-scope" role="tablist" aria-label="收件箱范围与筛选">
<button className={inboxScope === 'conv' ? 'active' : ''} onClick={() => store.setInboxScope('conv')}>本会话</button>
<button className={inboxScope === 'project' ? 'active' : ''} onClick={() => store.setInboxScope('project')}>
+2 -1
View File
@@ -893,7 +893,8 @@ export const store = {
},
async tidyCards() {
const done = state.cards.filter(c => c.status === 'done')
// U1:不收纳失败语义卡(failed,或旧 done+danger),避免把失败当完成清掉。
const done = state.cards.filter(c => c.status === 'done' && c.tone !== 'danger')
for (const c of done) {
try { const out = await api.cardDismiss(c.id); store.upsertCard(out.card) } catch { /* 单卡失败不阻断 */ }
}
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { settingInputAttrs } from '../src/components/SettingsModal'
// 2026-10-12 增量 3:配置输入按字段类型渲染——此前 SettingInput 默认分支无条件
// type="password",浏览器把整排设置项当凭据表单(「password 不在 form 中/缺少
// username」警告的来源之一),且普通文本被无谓遮蔽。新契约:仅秘密字段用密码框。
describe('settingInputAttrs', () => {
it('secret=true → 密码框 + new-password', () => {
expect(settingInputAttrs({ key: 'llm.apiKey', label: '密钥', type: 'string', secret: true } as any))
.toEqual({ type: 'password', autoComplete: 'new-password' })
})
it('type=secret → 密码框', () => {
expect(settingInputAttrs({ key: 'x', label: 'X', type: 'secret' } as any).type).toBe('password')
})
it('普通文本 → text + off(不遮蔽、不当凭据)', () => {
const a = settingInputAttrs({ key: 'llm.baseUrl', label: '地址', type: 'string' } as any)
expect(a).toEqual({ type: 'text', autoComplete: 'off' })
})
it('无类型标注的默认项 → text', () => {
expect(settingInputAttrs({ key: 'ui.language', label: '语言' } as any).type).toBe('text')
})
})
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { hasActiveProgressCard } from '../src/components/Workbench'
import type { ActivityCard } from '@ui'
function card(over: Partial<ActivityCard>): ActivityCard {
return {
id: 'card_1', projectId: 'prj_1', conversationId: 'cnv_1',
kind: 'progress', title: '材料整理', status: 'active', tone: 'info',
createdAt: '2026-10-12T10:00:00Z', updatedAt: '2026-10-12T10:00:00Z',
...over,
}
}
describe('hasActiveProgressCard', () => {
it('active/action_required progress 卡视为占用当前任务位', () => {
expect(hasActiveProgressCard([card({ status: 'active' })])).toBe(true)
expect(hasActiveProgressCard([card({ status: 'action_required' })])).toBe(true)
})
it('完成/失败 progress 或非 progress 不隐藏 CurrentTaskCard', () => {
expect(hasActiveProgressCard([card({ status: 'done', tone: 'ok' })])).toBe(false)
expect(hasActiveProgressCard([card({ status: 'failed', tone: 'danger' })])).toBe(false)
expect(hasActiveProgressCard([card({ kind: 'confirm', status: 'action_required' })])).toBe(false)
expect(hasActiveProgressCard([])).toBe(false)
})
})