fix(web): make the welcome notice scan-first

This commit is contained in:
NI0317
2026-07-30 22:58:52 +08:00
parent 564ba0df72
commit 04f8b30db1
10 changed files with 150 additions and 62 deletions
@@ -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-30-versioned-gui-welcome-onboarding.md
2026-07-30-versioned-gui-welcome-onboarding.md: 405c6fe833d995123cd15e5694cd5ef75a0cd03d
2026-07-30-versioned-gui-welcome-onboarding.zh.md: ea83aa958866ab3dcca749f362d43e4b29408e02
2026-07-30-versioned-gui-welcome-onboarding.md: 06ac9fbe5c10db872c7ea3989ff2e14f756965a0
2026-07-30-versioned-gui-welcome-onboarding.zh.md: e2d726368e6282e4f6043c225b665c1945228f2d
@@ -18,7 +18,7 @@ The GUI's credential onboarding begins with a DeepSeek-specific readiness check,
**Concurrent views converge without stale replacement.** The acknowledgement write omits `expectedRevision` deliberately: every tab writes the same version to one path, so the operation is idempotent and preserves sibling fields instead of rebuilding the section. `settings/document-updated` becomes `host/settings-changed`; an already mounted tab refetches and advances when another tab or an external editor commits the current version. The API proxy exposes this one product namespace through a closed allowlist beside configurable-provider namespaces, without treating its changes as model-catalog invalidations.
**The welcome modal has one completion path.** It renders no close icon or secondary action, installs no Escape handler, and assigns no click handler to the mask. Its mask starts below the 80 px top chrome and preserves `position:absolute`, zero left/right/bottom offsets, `rgba(0, 0, 0, 0.24)`, and `backdrop-filter: blur(2px)`. Continue is the sole button and receives initial focus.
**The welcome modal is scan-first and has one completion path.** Its hierarchy is a declaration title, one status sentence, one emphasized feedback callout, one consequence sentence, and a restrained quotation; the notice version changes whenever that authored copy changes materially. It renders no close icon or secondary action, installs no Escape handler, and assigns no click handler to the mask. Its mask starts below the 80 px top chrome and preserves `position:absolute`, zero left/right/bottom offsets, `rgba(0, 0, 0, 0.24)`, and `backdrop-filter: blur(2px)`. Continue is the sole button and receives initial focus.
## Alternatives considered
@@ -18,7 +18,7 @@ GUI 的凭据引导从 DeepSeek 专用的就绪状态检查开始,但内部测
**并发视图无需陈旧的整体替换即可收敛。** 确认写入有意省略 `expectedRevision`:每个标签页都向同一路径写入相同版本,因此该操作是幂等的,并会保留同级字段,而不是重建整个分节。`settings/document-updated` 会转为 `host/settings-changed`;另一个标签页或外部编辑器提交当前版本后,已挂载的标签页会重新拉取状态并推进。API 网关在可配置提供方 namespace 之外,通过封闭的允许列表暴露这一个产品 namespace,同时不会把它的变更视为模型目录失效事件。
**欢迎模态窗口只有一条完成路径。** 界面不渲染关闭图标或次要操作,不安装 Escape 处理器,也不为遮罩添加点击处理器。遮罩从顶部 80 px 的界面框架下方开始,并保留 `position:absolute`、left/right/bottom 偏移量为零、`rgba(0, 0, 0, 0.24)``backdrop-filter: blur(2px)`。「继续」是唯一按钮,并会获得初始焦点。
**欢迎模态窗口以便于扫读为先,且只有一条完成路径。** 其信息层级依次为声明标题、一句状态说明、一则重点突出的反馈提示、一句影响说明和一则克制的引语;只要这份文案发生实质变化,就同步提升通知版本。界面不渲染关闭图标或次要操作,不安装 Escape 处理器,也不为遮罩添加点击处理器。遮罩从顶部 80 px 的界面框架下方开始,并保留 `position:absolute`、left/right/bottom 偏移量为零、`rgba(0, 0, 0, 0.24)``backdrop-filter: blur(2px)`。「继续」是唯一按钮,并会获得初始焦点。
## 曾考虑的替代方案
@@ -48,7 +48,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
it('stores a key write-only and observes configured state without restarting', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-deepseek-config'))
const welcome = page.getByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.paragraphs[0] })
const welcome = page.getByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.title })
await welcome.waitFor({ timeout: 15_000 })
const welcomeAria = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(WELCOME_EXPECTED, welcomeAria, MODE)
@@ -132,7 +132,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
await page.reload({ waitUntil: 'load' })
acknowledgeReloadConnectionLoss(tripwire, secondReloadWarnings)
await page.waitForSelector('[class*="frame"]', { timeout: 15_000 })
expect(await page.getByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.paragraphs[0] }).count()).toBe(0)
expect(await page.getByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.title }).count()).toBe(0)
expect(await page.getByRole('dialog', { name: '添加一个 API Key 开始使用' }).count()).toBe(0)
// A different stored copy version represents an intentional version bump:
@@ -1,6 +1,8 @@
- dialog "感谢您愿意拨冗试用 DeepSeek Harness。":
- heading "感谢您愿意拨冗试用 DeepSeek Harness。" [level=2]
- paragraph: 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝
- paragraph: “如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也可能促使我们重新审视,甚至推翻已有的设计。
- paragraph: 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至反而为工作添了麻烦,请在企业微信群中留言,将使用感受告诉我们。每一条反馈,都会帮助我们把它打磨得更好
- dialog "内测声明":
- heading "内测声明" [level=2]
- paragraph: 感谢您试用 DeepSeek Harness。目前仍处于内部测试阶段,部分功能与体验还在持续打磨
- strong: 我们最想听见:失败、困惑和不顺手
- paragraph: 如果它帮到您,甚至工作添了麻烦,请在企业微信群告诉我们
- paragraph: 真实使用中的每一个问题,都可能促使我们重新审视,甚至推翻已有设计。
- paragraph: “如切如磋,如琢如磨。”
- button "继续"
@@ -24,7 +24,7 @@
.dialog {
position: relative;
z-index: 1;
width: min(640px, calc(100vw - 48px));
width: min(600px, calc(100vw - 48px));
max-height: calc(100vh - 128px);
padding: 32px;
box-sizing: border-box;
@@ -40,21 +40,65 @@
font-size: 20px;
line-height: 30px;
font-weight: 600;
letter-spacing: -0.01em;
}
.copy {
display: flex;
flex-direction: column;
gap: 14px;
margin-top: 18px;
font-size: 14px;
line-height: 24px;
.lead,
.closing,
.quote,
.feedback p,
.error {
margin: 0;
}
.lead {
margin-top: 12px;
font-size: 16px;
line-height: 25px;
color: var(--dsw-alias-label-secondary);
}
.copy p,
.error {
margin: 0;
.feedback {
margin-top: 20px;
padding: 16px 18px;
border-radius: 14px;
border: 1px solid var(--dsw-alias-border-l1);
background: var(--dsw-alias-bg-module-platform);
font-size: 15px;
line-height: 24px;
}
.feedback strong {
display: block;
margin-bottom: 4px;
font-weight: 600;
}
.feedback p,
.closing {
color: var(--dsw-alias-label-secondary);
}
.closing {
margin-top: 16px;
font-size: 15px;
line-height: 24px;
}
.quote {
font-size: 14px;
line-height: 22px;
color: var(--dsw-alias-label-secondary);
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid var(--dsw-alias-border-l1);
}
.error {
@@ -65,6 +109,32 @@
}
.primary {
width: 100%;
margin-top: 24px;
min-width: 104px;
transition: transform 140ms cubic-bezier(0.23, 1, 0.32, 1);
}
.primary:active:not(:disabled) {
transform: scale(0.97);
}
@media (prefers-reduced-motion: reduce) {
.primary {
transition: none;
}
}
@media (max-width: 560px) {
.dialog {
padding: 24px;
}
.footer {
align-items: stretch;
flex-direction: column;
gap: 14px;
}
.primary {
width: 100%;
}
}
@@ -47,22 +47,26 @@ export function WelcomeNotice(props: WelcomeNoticeProps): ReactNode {
<div className={css.overlay} role="presentation">
<div className={css.mask} aria-hidden="true" />
<section className={css.dialog} role="dialog" aria-modal="true" aria-labelledby="welcome-notice-title">
<h2 id="welcome-notice-title" className={css.title}>{t('welcome.paragraph.0')}</h2>
<div className={css.copy}>
<p>{t('welcome.paragraph.1')}</p>
<p>{t('welcome.paragraph.2')}</p>
<p>{t('welcome.paragraph.3')}</p>
<h2 id="welcome-notice-title" className={css.title}>{t('welcome.title')}</h2>
<p className={css.lead}>{t('welcome.lead')}</p>
<div className={css.feedback}>
<strong>{t('welcome.feedbackTitle')}</strong>
<p>{t('welcome.feedbackBody')}</p>
</div>
<p className={css.closing}>{t('welcome.closing')}</p>
{state.error === null ? null : <p className={css.error} role="alert">{t('welcome.error')}</p>}
<Button
variant="primary"
className={css.primary}
autoFocus
disabled={state.status === 'saving'}
onClick={() => { void acknowledge() }}
>
{t('welcome.continue')}
</Button>
<div className={css.footer}>
<p className={css.quote}>{t('welcome.quote')}</p>
<Button
variant="primary"
className={css.primary}
autoFocus
disabled={state.status === 'saving'}
onClick={() => { void acknowledge() }}
>
{t('welcome.continue')}
</Button>
</div>
</section>
</div>
)
@@ -26,10 +26,12 @@ export const zh: LocaleDict = {
'permission.title': '权限',
'permission.desc': '选择默认权限模式',
'toolcall.title': '工具调用',
'welcome.paragraph.0': WELCOME_NOTICE_COPY.zh.paragraphs[0],
'welcome.paragraph.1': WELCOME_NOTICE_COPY.zh.paragraphs[1],
'welcome.paragraph.2': WELCOME_NOTICE_COPY.zh.paragraphs[2],
'welcome.paragraph.3': WELCOME_NOTICE_COPY.zh.paragraphs[3],
'welcome.title': WELCOME_NOTICE_COPY.zh.title,
'welcome.lead': WELCOME_NOTICE_COPY.zh.lead,
'welcome.feedbackTitle': WELCOME_NOTICE_COPY.zh.feedbackTitle,
'welcome.feedbackBody': WELCOME_NOTICE_COPY.zh.feedbackBody,
'welcome.closing': WELCOME_NOTICE_COPY.zh.closing,
'welcome.quote': WELCOME_NOTICE_COPY.zh.quote,
'welcome.continue': WELCOME_NOTICE_COPY.zh.continueLabel,
'welcome.error': '暂时无法保存确认状态,请重试。',
}
@@ -44,10 +46,12 @@ export const en: LocaleDict = {
'permission.title': 'Permission',
'permission.desc': 'Choose default permission mode',
'toolcall.title': 'Tool Call',
'welcome.paragraph.0': WELCOME_NOTICE_COPY.en.paragraphs[0],
'welcome.paragraph.1': WELCOME_NOTICE_COPY.en.paragraphs[1],
'welcome.paragraph.2': WELCOME_NOTICE_COPY.en.paragraphs[2],
'welcome.paragraph.3': WELCOME_NOTICE_COPY.en.paragraphs[3],
'welcome.title': WELCOME_NOTICE_COPY.en.title,
'welcome.lead': WELCOME_NOTICE_COPY.en.lead,
'welcome.feedbackTitle': WELCOME_NOTICE_COPY.en.feedbackTitle,
'welcome.feedbackBody': WELCOME_NOTICE_COPY.en.feedbackBody,
'welcome.closing': WELCOME_NOTICE_COPY.en.closing,
'welcome.quote': WELCOME_NOTICE_COPY.en.quote,
'welcome.continue': WELCOME_NOTICE_COPY.en.continueLabel,
'welcome.error': 'The acknowledgement could not be saved. Please try again.',
}
@@ -8,26 +8,26 @@ export const WELCOME_NOTICE_ACK_FIELD = 'welcomeNoticeVersion'
* Bump only when the notice changes materially and every user should see it
* again. The acknowledgement is compared for exact equality.
*/
export const WELCOME_NOTICE_VERSION = '2026-07-30.1'
export const WELCOME_NOTICE_VERSION = '2026-07-30.2'
/** The complete editable welcome notice in both supported GUI locales. */
export const WELCOME_NOTICE_COPY = {
zh: {
paragraphs: [
'感谢您愿意拨冗试用 DeepSeek Harness。',
'目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。',
'“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也可能促使我们重新审视,甚至推翻已有的设计。',
'我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至反而为工作平添了麻烦,请在企业微信群中留言,将使用感受告诉我们。每一条反馈,都会帮助我们把它打磨得更好。',
],
title: '内测声明',
lead: '感谢您试用 DeepSeek Harness。目前仍处于内部测试阶段,部分功能与体验还在持续打磨。',
feedbackTitle: '我们最想听见:失败、困惑和不顺手',
feedbackBody: '如果它没帮到您,甚至给工作添了麻烦,请在企业微信群告诉我们。',
closing: '真实使用中的每一个问题,都可能促使我们重新审视,甚至推翻已有设计。',
quote: '“如切如磋,如琢如磨。”',
continueLabel: '继续',
},
en: {
paragraphs: [
'Thank you for taking the time to try DeepSeek Harness.',
'This version is still in internal testing. Some features remain unfinished, and parts of the experience may feel rough.',
'“As one cuts and files, as one chisels and polishes.” A product grows through real encounters and candid feedback. Problems you uncover in real use may prompt us to reconsider—or even overturn—our existing designs.',
'We especially want to hear about failures, confusion, and friction. If it did not help you, or even made your work harder, please leave a message in the company WeChat group and tell us about your experience. Every piece of feedback helps us refine it.',
],
title: 'Internal Testing Notice',
lead: 'Thank you for trying DeepSeek Harness. This version is still in internal testing, and some features and experiences remain under refinement.',
feedbackTitle: 'What we most want to hear: failures, confusion, and friction',
feedbackBody: 'If it did not help—or even made your work harder—please tell us in the company WeChat group.',
closing: 'Every problem found in real use may prompt us to reconsider, or even overturn, an existing design.',
quote: '“As one cuts and files, as one chisels and polishes.”',
continueLabel: 'Continue',
},
} as const
@@ -54,10 +54,18 @@ function mount(version?: string, mutateImpl: () => Promise<unknown> = () => Prom
describe('WelcomeNotice', () => {
it('renders the owner copy with one primary action and no dismissal control', async () => {
const h = mount()
const dialog = await screen.findByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.paragraphs[0] })
for (const paragraph of WELCOME_NOTICE_COPY.zh.paragraphs) {
expect(screen.getByText(paragraph)).toBeTruthy()
const dialog = await screen.findByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.title })
for (const text of [
WELCOME_NOTICE_COPY.zh.title,
WELCOME_NOTICE_COPY.zh.lead,
WELCOME_NOTICE_COPY.zh.feedbackTitle,
WELCOME_NOTICE_COPY.zh.feedbackBody,
WELCOME_NOTICE_COPY.zh.closing,
WELCOME_NOTICE_COPY.zh.quote,
]) {
expect(screen.getByText(text)).toBeTruthy()
}
expect(dialog.textContent?.match(/感谢您试用 DeepSeek Harness/g) ?? []).toHaveLength(1)
const buttons = dialog.querySelectorAll('button')
expect(buttons).toHaveLength(1)
expect(screen.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel })).toBeTruthy()