Merge branch 'master' into worktree/locale-browser-default

This commit is contained in:
creatixchu
2026-07-31 16:34:06 +08:00
parent e5563ae433
commit c7f706acba
29 changed files with 67648 additions and 0 deletions

View File

@@ -0,0 +1,164 @@
.page {
position: relative;
z-index: 1;
width: min(640px, calc(100vw - 64px));
max-height: 100vh;
padding: clamp(64px, 9vh, 104px) 0 40px;
box-sizing: border-box;
overflow-y: auto;
color: var(--dsw-alias-label-primary);
--welcome-ease-out: cubic-bezier(0.23, 1, 0.32, 1);
}
.brand {
display: flex;
align-items: center;
margin-bottom: 42px;
color: var(--dsw-alias-label-primary);
}
.title {
margin: 0;
font-size: 28px;
line-height: 36px;
font-weight: 600;
letter-spacing: -0.02em;
outline: none;
}
.opening,
.status,
.reflection,
.feedback,
.error {
margin: 0;
}
.opening {
margin-top: 30px;
}
.status {
margin-top: 18px;
}
.reflection {
margin-top: 36px;
padding: 0;
}
.feedback {
margin-top: 30px;
}
.opening,
.status,
.reflection,
.feedback {
font-size: 16px;
line-height: 28px;
color: var(--dsw-alias-label-secondary);
}
.feedback strong {
color: inherit;
font-weight: 500;
}
.footer {
display: flex;
justify-content: flex-end;
margin-top: 32px;
}
.error {
margin-top: 20px;
font-size: 14px;
line-height: 22px;
color: var(--dsw-alias-state-error-primary);
}
.primary {
min-width: 120px;
transition: transform 140ms var(--welcome-ease-out);
}
.primary:active:not(:disabled) {
transform: scale(0.97);
}
.brand,
.title,
.opening,
.status,
.reflection,
.feedback,
.footer {
animation: welcome-enter 280ms var(--welcome-ease-out) both;
}
.title { animation-delay: 40ms; }
.opening { animation-delay: 80ms; }
.status { animation-delay: 120ms; }
.reflection { animation-delay: 160ms; }
.feedback { animation-delay: 200ms; }
.footer { animation-delay: 240ms; }
@keyframes welcome-enter {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.brand,
.title,
.opening,
.status,
.reflection,
.feedback,
.footer {
animation: none;
}
.primary {
transition: none;
}
}
@media (max-width: 560px) {
.page {
width: calc(100vw - 40px);
padding-top: 38px;
}
.brand {
margin-bottom: 30px;
}
.opening {
margin-top: 24px;
}
.reflection {
margin-top: 28px;
}
.feedback {
margin-top: 28px;
}
.footer {
margin-top: 30px;
}
.primary {
width: 100%;
}
}

View File

@@ -0,0 +1,87 @@
/** Product-wide, versioned first-run welcome step. */
import { useCallback, useEffect, useRef } from 'react'
import type { ReactNode } from 'react'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { BrandWordmark, Button } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import type { WelcomeNoticeState, WelcomeNoticeStore } from './welcome-store.ts'
import css from './WelcomeNotice.module.css'
function emphasizedFeedback(paragraph: string, emphasis: string): ReactNode {
const index = paragraph.indexOf(emphasis)
/* v8 ignore next -- both locale values derive from one owner object that contains the emphasis */
if (index < 0) return paragraph
return (
<>
{paragraph.slice(0, index)}
<strong>{emphasis}</strong>
{paragraph.slice(index + emphasis.length)}
</>
)
}
/** Registrant-owned dependencies of {@link WelcomeNotice}. */
export interface WelcomeNoticeInjected {
controller: WelcomeNoticeStore
useSnapshot: SnapshotSelectorHook<WelcomeNoticeState>
}
/** Coordinator owner props plus the welcome step's injected face. */
export type WelcomeNoticeProps =
PropsRuntime<'settings.onboarding'> & PropsLocale<'settings'> & WelcomeNoticeInjected
/** Render the mandatory notice until its current version commits durably. */
export function WelcomeNotice(props: WelcomeNoticeProps): ReactNode {
const { complete, controller, useSnapshot, t } = props
const state = useSnapshot(snapshot => snapshot)
const finished = useRef(false)
const titleRef = useRef<HTMLHeadingElement | null>(null)
const finish = useCallback((): void => {
if (finished.current) return
finished.current = true
complete()
}, [complete])
useEffect(() => {
if (state.status === 'idle') void controller.load()
}, [controller, state.status])
useEffect(() => {
if (state.acknowledged) finish()
}, [finish, state.acknowledged])
useEffect(() => {
if (state.status === 'ready' && !state.acknowledged) titleRef.current?.focus()
}, [state.acknowledged, state.status])
if (state.status === 'idle' || state.status === 'loading' || state.acknowledged) return null
const acknowledge = async (): Promise<void> => {
if (await controller.acknowledge()) finish()
}
return (
<section className={css.page} role="region" aria-labelledby="welcome-notice-title">
<div className={css.brand} aria-hidden="true"><BrandWordmark size={24} /></div>
<h2 ref={titleRef} id="welcome-notice-title" className={css.title} tabIndex={-1}>{t('welcome.title')}</h2>
<p className={css.opening}>{t('welcome.paragraph.0')}</p>
<p className={css.status}>{t('welcome.paragraph.1')}</p>
<blockquote className={css.reflection}>{t('welcome.paragraph.2')}</blockquote>
<p className={css.feedback}>
{emphasizedFeedback(t('welcome.paragraph.3'), t('welcome.feedbackEmphasis'))}
</p>
{state.error === null ? null : <p className={css.error} role="alert">{t('welcome.error')}</p>}
<div className={css.footer}>
<Button
variant="primary"
className={css.primary}
disabled={state.status === 'saving'}
onClick={() => { void acknowledge() }}
>
{t('welcome.continue')}
</Button>
</div>
</section>
)
}

View File

@@ -0,0 +1,108 @@
/** Durable welcome-notice state over the Host settings document. */
import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import {
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION,
} from '../onboarding-copy.ts'
/** State rendered by the welcome step. */
export interface WelcomeNoticeState {
status: 'idle' | 'loading' | 'ready' | 'saving' | 'error'
acknowledged: boolean
error: string | null
}
function messageOf(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
function acknowledgementOf(view: SettingsNamespaceView): string | undefined {
if (typeof view.value !== 'object' || view.value === null) return undefined
const value = (view.value as Record<string, unknown>)[WELCOME_NOTICE_ACK_FIELD]
return typeof value === 'string' ? value : undefined
}
/** Coordinates welcome acknowledgement reads and the sole durable write. */
export class WelcomeNoticeStore {
/** uSES-safe state source shared by the registered welcome step. */
readonly store: SnapshotStore<WelcomeNoticeState> = createSnapshotStore({
status: 'idle', acknowledged: false, error: null,
})
private generation = 0
/** @param api - settings wire face used for durable reads and writes. */
constructor(private readonly api: Pick<IApiClient, 'settings'>) {}
/** Load the current acknowledgement from the Host settings document. */
async load(): Promise<void> {
const generation = ++this.generation
this.store.update((state) => { state.status = 'loading'; state.error = null })
try {
const response = await this.api.settings.describe({})
if (!response.result.ok) throw new Error(response.result.error.message)
const view = response.result.value.namespaces.find(
candidate => candidate.ns === WELCOME_NOTICE_SETTINGS_NAMESPACE,
)
if (view === undefined) throw new Error('welcome acknowledgement settings are unavailable')
if (generation !== this.generation) return
this.store.update((state) => {
state.status = 'ready'
state.acknowledged = acknowledgementOf(view) === WELCOME_NOTICE_VERSION
state.error = null
})
} catch (error) {
if (generation !== this.generation) return
this.store.update((state) => {
state.status = 'error'
state.acknowledged = false
state.error = messageOf(error)
})
}
}
/**
* Persist this copy version. The path mutation is idempotent across tabs and
* preserves every sibling setting; failure leaves the step unacknowledged.
* @returns true only when the Host committed the acknowledgement.
*/
async acknowledge(): Promise<boolean> {
const generation = ++this.generation
this.store.update((state) => { state.status = 'saving'; state.error = null })
try {
const response = await this.api.settings.mutate({
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
ops: [{ op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION }],
})
if (!response.result.ok) throw new Error(response.result.error.message)
if (generation === this.generation) {
this.store.update((state) => {
state.status = 'ready'
state.acknowledged = true
state.error = null
})
}
return true
} catch (error) {
if (generation === this.generation) {
this.store.update((state) => {
state.status = 'error'
state.acknowledged = false
state.error = messageOf(error)
})
}
return false
}
}
}
/**
* Refresh only after the welcome step has begun reading durable state.
* @param controller - welcome state owner whose current status decides whether to load.
*/
export function refreshWelcomeIfLoaded(controller: WelcomeNoticeStore): void {
if (controller.store.getSnapshot().status === 'idle') return
void controller.load()
}

View File

@@ -0,0 +1,37 @@
/** Durable settings namespace for product-wide GUI onboarding facts. */
export const WELCOME_NOTICE_SETTINGS_NAMESPACE = 'ui-onboarding'
/** Field storing the last welcome notice version the user acknowledged. */
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.5'
/** The complete editable welcome notice in both supported GUI locales. */
export const WELCOME_NOTICE_COPY = {
zh: {
title: '内测声明',
paragraphs: [
'感谢您愿意拨冗试用 DeepSeek Harness。',
'目前的版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。',
'“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。',
'我们尤其希望听见那些失败、困惑与不顺手的时刻——如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。',
],
feedbackEmphasis: '如果您有任何反馈与建议,请在企业微信群中留言告诉我们',
continueLabel: '继续',
},
en: {
title: 'Internal Testing Notice',
paragraphs: [
'Thank you for taking the time to try DeepSeek Harness.',
'This version is still in internal testing. Its functionality still needs improvement, and the experience may feel a little rough.',
'“As one cuts and files, as one chisels and polishes.” A product grows through real encounters and candid feedback. Problems you discover 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 you have any feedback or suggestions, please leave us a message in the company WeChat group. Every piece of feedback helps us refine it.',
],
feedbackEmphasis: 'If you have any feedback or suggestions, please leave us a message in the company WeChat group',
continueLabel: 'Continue',
},
} as const

View File

@@ -0,0 +1,29 @@
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
import { apply } from '../src/index.ts'
import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../src/onboarding-copy.ts'
class MemorySettings extends Settings {
readonly writable = true
protected load(): Promise<Record<string, unknown>> { return Promise.resolve({}) }
protected persist(_ns: SettingsNamespace, _section: Record<string, unknown>): Promise<void> {
return Promise.resolve()
}
}
describe('ui-settings-general host', () => {
it('registers and disposes the durable onboarding namespace with its fiber', async () => {
const ctx = new Context()
await ctx.plugin(MemorySettings).await()
const fiber = ctx.plugin({ apply })
await fiber.await()
expect(ctx.settings.describe().map(row => row.ns)).toContain(
settingsNamespace(WELCOME_NOTICE_SETTINGS_NAMESPACE),
)
await fiber.dispose()
expect(ctx.settings.describe().map(row => row.ns)).not.toContain(
settingsNamespace(WELCOME_NOTICE_SETTINGS_NAMESPACE),
)
})
})

View File

@@ -0,0 +1,101 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx'
import type { WelcomeNoticeProps } from '../src/client/WelcomeNotice.tsx'
import { WelcomeNoticeStore } from '../src/client/welcome-store.ts'
import { zh } from '../src/client/locales.ts'
import {
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE,
WELCOME_NOTICE_VERSION,
} from '../src/onboarding-copy.ts'
afterEach(cleanup)
function response<T>(value: T) {
return { rpcId: 'welcome-rpc' as never, result: { ok: true as const, value } }
}
function mount(version?: string, mutateImpl: () => Promise<unknown> = () => Promise.resolve(response({}))) {
const mutate = vi.fn(mutateImpl)
const api = {
settings: {
describe: () => Promise.resolve(response({
writable: true,
namespaces: [{
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
schema: {},
value: version === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: version },
applies: 'live' as const,
secrets: [],
revision: 0,
}],
})),
mutate,
},
}
const controller = new WelcomeNoticeStore(api as never)
const complete = vi.fn()
const unusedHook = (() => { throw new Error('unused standard hook') }) as never
const props: WelcomeNoticeProps = {
stepId: 'welcome-notice',
complete,
openSection: vi.fn(),
useSessions: unusedHook,
useWorkspaces: unusedHook,
controller,
useSnapshot: bindSnapshotSelector(controller.store),
t: key => key in zh ? zh[key as keyof typeof zh] : key,
}
return { ...render(<WelcomeNotice {...props} />), complete, controller, mutate }
}
describe('WelcomeNotice', () => {
it('renders the owner copy with one primary action and no dismissal control', async () => {
const h = mount()
const page = await screen.findByRole('region', { name: WELCOME_NOTICE_COPY.zh.title })
expect(screen.getByText(WELCOME_NOTICE_COPY.zh.title)).toBeTruthy()
for (const text of WELCOME_NOTICE_COPY.zh.paragraphs) expect(page.textContent).toContain(text)
expect(page.textContent?.match(/感谢您愿意拨冗试用 DeepSeek Harness/g) ?? []).toHaveLength(1)
const buttons = page.querySelectorAll('button')
expect(buttons).toHaveLength(1)
expect(screen.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel })).toBeTruthy()
expect(document.activeElement).toBe(screen.getByRole('heading', { name: WELCOME_NOTICE_COPY.zh.title }))
fireEvent.keyDown(document, { key: 'Escape' })
expect(h.complete).not.toHaveBeenCalled()
expect(screen.getByRole('region')).toBeTruthy()
})
it('completes only after the acknowledgement write commits', async () => {
const h = mount()
await screen.findByRole('region')
fireEvent.click(screen.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }))
await act(async () => { await Promise.resolve() })
expect(h.mutate).toHaveBeenCalledOnce()
expect(h.complete).toHaveBeenCalledOnce()
})
it('skips itself when this exact version was already acknowledged', async () => {
const h = mount(WELCOME_NOTICE_VERSION)
await act(async () => { await h.controller.load() })
expect(screen.queryByRole('region')).toBeNull()
expect(h.complete).toHaveBeenCalledOnce()
})
it('keeps the sole action disabled while saving and reports a refused write', async () => {
let resolveWrite!: (value: unknown) => void
const write = new Promise<unknown>((resolve) => { resolveWrite = resolve })
const h = mount(undefined, () => write)
await screen.findByRole('region')
const action = screen.getByRole<HTMLButtonElement>('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel })
fireEvent.click(action)
expect(action.disabled).toBe(true)
resolveWrite({
rpcId: 'welcome-refused' as never,
result: { ok: false, error: { code: 'settings-rejected', message: 'read only', details: { ns: WELCOME_NOTICE_SETTINGS_NAMESPACE } } },
})
expect((await screen.findByRole('alert')).textContent).toBe('暂时无法保存确认状态,请重试。')
expect(h.complete).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,166 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcResponse } from '@deepseek-ai/dsh-client-connection/client'
import { WelcomeNoticeStore } from '../src/client/welcome-store.ts'
import { refreshWelcomeIfLoaded } from '../src/client/welcome-store.ts'
import {
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION,
} from '../src/onboarding-copy.ts'
let rpc = 0
function ok<T>(value: T): RpcResponse<T> {
return { rpcId: `welcome-${rpc++}` as never, result: { ok: true, value } }
}
function namespace(version?: string) {
return {
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
schema: {},
value: version === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: version },
applies: 'live' as const,
secrets: [],
revision: 0,
}
}
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
return { promise, resolve, reject }
}
describe('WelcomeNoticeStore', () => {
it('acknowledges only the exact current copy version', async () => {
for (const [version, acknowledged] of [
[undefined, false],
['older-copy', false],
[WELCOME_NOTICE_VERSION, true],
] as const) {
const api = {
settings: {
describe: vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: [namespace(version)] }))),
},
}
const controller = new WelcomeNoticeStore(api as never)
await controller.load()
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged })
}
})
it('persists the owner version through one idempotent path mutation', async () => {
const mutate = vi.fn(() => Promise.resolve(ok(namespace(WELCOME_NOTICE_VERSION))))
const controller = new WelcomeNoticeStore({ settings: { mutate } } as never)
await expect(controller.acknowledge()).resolves.toBe(true)
expect(mutate).toHaveBeenCalledWith({
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
ops: [{ op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION }],
})
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true })
})
it('keeps the notice pending when loading or persistence fails', async () => {
const load = new WelcomeNoticeStore({
settings: { describe: () => Promise.reject(new Error('offline')) },
} as never)
await load.load()
expect(load.store.getSnapshot()).toEqual({ status: 'error', acknowledged: false, error: 'offline' })
const save = new WelcomeNoticeStore({
settings: { mutate: () => Promise.reject(new Error('disk full')) },
} as never)
await expect(save.acknowledge()).resolves.toBe(false)
expect(save.store.getSnapshot()).toEqual({ status: 'error', acknowledged: false, error: 'disk full' })
const nonError = new WelcomeNoticeStore({
// Durable/wire failures are unknown; exercise containment of a non-Error rejection.
// oxlint-disable-next-line typescript/prefer-promise-reject-errors
settings: { describe: () => Promise.reject('offline string') },
} as never)
await nonError.load()
expect(nonError.store.getSnapshot().error).toBe('offline string')
})
it('reports business failures, missing namespaces, and malformed durable values', async () => {
for (const describe of [
() => Promise.resolve({
rpcId: 'failed' as never,
result: { ok: false as const, error: { code: 'internal' as const, message: 'denied', details: {} } },
}),
() => Promise.resolve(ok({ writable: true, namespaces: [] })),
]) {
const controller = new WelcomeNoticeStore({ settings: { describe } } as never)
await controller.load()
expect(controller.store.getSnapshot().status).toBe('error')
}
for (const value of [null, 42, { [WELCOME_NOTICE_ACK_FIELD]: 42 }]) {
const controller = new WelcomeNoticeStore({
settings: { describe: () => Promise.resolve(ok({
writable: true,
namespaces: [{ ...namespace(), value }],
})) },
} as never)
await controller.load()
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: false })
}
const save = new WelcomeNoticeStore({
settings: { mutate: () => Promise.resolve({
rpcId: 'failed-save' as never,
result: { ok: false, error: { code: 'settings-rejected', message: 'denied', details: { ns: WELCOME_NOTICE_SETTINGS_NAMESPACE } } },
}) },
} as never)
await expect(save.acknowledge()).resolves.toBe(false)
expect(save.store.getSnapshot().error).toBe('denied')
})
it('lets the latest load win over stale success and failure', async () => {
const first = deferred<ReturnType<typeof ok>>()
const describe = vi.fn()
.mockImplementationOnce(() => first.promise)
.mockImplementationOnce(() => Promise.resolve(ok({ writable: true, namespaces: [namespace()] })))
const controller = new WelcomeNoticeStore({ settings: { describe } } as never)
const stale = controller.load()
await controller.load()
first.resolve(ok({ writable: true, namespaces: [namespace(WELCOME_NOTICE_VERSION)] }))
await stale
expect(controller.store.getSnapshot().acknowledged).toBe(false)
const failed = deferred<ReturnType<typeof ok>>()
describe
.mockImplementationOnce(() => failed.promise)
.mockImplementationOnce(() => Promise.resolve(ok({ writable: true, namespaces: [namespace(WELCOME_NOTICE_VERSION)] })))
const staleFailure = controller.load()
await controller.load()
failed.reject('stale failure')
await staleFailure
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true, error: null })
})
it('contains stale acknowledgement settlements and refreshes only a loaded store', async () => {
const write = deferred<ReturnType<typeof ok>>()
const describe = vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: [namespace()] })))
const controller = new WelcomeNoticeStore({
settings: { mutate: () => write.promise, describe },
} as never)
refreshWelcomeIfLoaded(controller)
expect(describe).not.toHaveBeenCalled()
const staleWrite = controller.acknowledge()
await controller.load()
write.resolve(ok(namespace(WELCOME_NOTICE_VERSION)))
await expect(staleWrite).resolves.toBe(true)
expect(controller.store.getSnapshot().acknowledged).toBe(false)
refreshWelcomeIfLoaded(controller)
await vi.waitFor(() => { expect(describe).toHaveBeenCalledTimes(2) })
const failedWrite = deferred<ReturnType<typeof ok>>()
const staleFailure = new WelcomeNoticeStore({
settings: { mutate: () => failedWrite.promise, describe },
} as never)
const pending = staleFailure.acknowledge()
await staleFailure.load()
failedWrite.reject('late failure')
await expect(pending).resolves.toBe(false)
expect(staleFailure.store.getSnapshot().status).toBe('ready')
})
})