feat(web): add versioned first-run welcome

This commit is contained in:
NI0317
2026-07-30 22:17:56 +08:00
parent b6130b178d
commit bef8db3add
49 changed files with 1096 additions and 115 deletions

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
README.md: c392d745021c0fc6a752cf71dd0506a435106c50
README.zh.md: 83ab81e01eae435a74b50fa363a4de203c483002
# pnpm run verify-translation-pairing --write packages/client/ui-settings-general/README.md
README.md: 3ae3f58bd00172ed9b547c01a354023c224f6a1c
README.zh.md: ffdaf0e4314daa947a4183e2b23b7b1650de7611

View File

@@ -2,7 +2,9 @@
English | [中文](README.zh.md)
Settings ownerless-copy plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the General section (Permission/Tool Call skeleton rows + the `settings.general.item` slot declaration), and the `settings` dictionaries. Feature-owned rows (Language, Appearance) and sections (Models) stay with their feature packages.
Settings ownerless-copy and product-onboarding plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the General section (Permission/Tool Call skeleton rows + the `settings.general.item` slot declaration), the `settings` dictionaries, and the first ordered welcome step. Feature-owned rows (Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages.
`src/onboarding-copy.ts` is the single editable owner of the complete Chinese and English notice plus `WELCOME_NOTICE_VERSION`. The Host half registers `ui-onboarding` in the user-settings seam; the browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A different version deliberately presents the notice again. The welcome UI has no close, Escape, mask-click, or secondary path, and none of its copy or acknowledgement enters a Session log or model request.
## Model Experience

View File

@@ -2,7 +2,9 @@
[English](README.md) | 中文
设置界面文案插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容,「通用」分区(「权限」/「工具调用」骨架行和 `settings.general.item` slot 声明),以及 `settings` 字典。归具体功能所有的行(「语言」、「外观」)和分区(「模型」)仍由各自的功能包提供。
设置界面无特定功能归属的文案与产品引导插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容,「通用」分区(「权限」/「工具调用」骨架行和 `settings.general.item` slot 声明)、`settings` 字典,以及第一个有序欢迎步骤。归具体功能所有的行(「语言」、「外观」)、分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。
`src/onboarding-copy.ts` 是完整中英文通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源。宿主端在 user-settings seam 中注册 `ui-onboarding`;浏览器比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后,无需重新加载即可推进。版本不同时,系统会有意重新显示通知。欢迎界面没有关闭操作、Escape、点击遮罩或次要操作路径,其文案和确认状态均不会进入会话日志或模型请求。
## 模型体验

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-ui-settings-general",
"description": "Settings ownerless-copy plugin: the General section (skeleton rows + item slot), the shell trigger/header chrome content, and the settings dictionaries",
"description": "Settings ownerless-copy and product onboarding plugin: General, shell chrome, dictionaries, and the versioned welcome notice",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -26,7 +26,8 @@
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-client-locale"
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-connection"
],
"platform": "web"
},
@@ -35,22 +36,30 @@
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-settings": "workspace:^",
"schemastery": "^3.18.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-connection": "^0.0.1",
"@deepseek-ai/dsh-client-locale": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-settings": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-client-web-react": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7",

View File

@@ -0,0 +1,70 @@
.overlay {
position: fixed;
inset: 0;
z-index: 1100;
display: flex;
align-items: center;
justify-content: center;
padding-top: 80px;
box-sizing: border-box;
}
/* Mask */
.mask {
position: absolute;
left: 0px;
right: 0px;
top: 80px;
bottom: 0px;
background: rgba(0, 0, 0, 0.24);
/* Mask-blur */
backdrop-filter: blur(2px);
}
.dialog {
position: relative;
z-index: 1;
width: min(640px, calc(100vw - 48px));
max-height: calc(100vh - 128px);
padding: 32px;
box-sizing: border-box;
overflow-y: auto;
border-radius: 24px;
background: var(--dsw-alias-bg-layer-2);
box-shadow: var(--dsw-shadow-lv3);
color: var(--dsw-alias-label-primary);
}
.title {
margin: 0;
font-size: 20px;
line-height: 30px;
font-weight: 600;
}
.copy {
display: flex;
flex-direction: column;
gap: 14px;
margin-top: 18px;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-secondary);
}
.copy p,
.error {
margin: 0;
}
.error {
margin-top: 14px;
font-size: 13px;
line-height: 20px;
color: var(--dsw-alias-state-error-primary);
}
.primary {
width: 100%;
margin-top: 24px;
}

View File

@@ -0,0 +1,69 @@
/** Product-wide, versioned first-run welcome step. */
import { useCallback, useEffect, useRef } from 'react'
import type { ReactNode } from 'react'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { 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'
/** Registrant-owned dependencies of {@link WelcomeNotice}. */
export interface WelcomeNoticeInjected {
controller: WelcomeNoticeStore
useSnapshot: SnapshotSelectorHook<WelcomeNoticeState>
t: (key: string) => string
}
/** Coordinator owner props plus the welcome step's injected face. */
export type WelcomeNoticeProps = PropsRuntime<'settings.onboarding'> & 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 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])
if (state.status === 'idle' || state.status === 'loading' || state.acknowledged) return null
const acknowledge = async (): Promise<void> => {
if (await controller.acknowledge()) finish()
}
return (
<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>
</div>
{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>
</section>
</div>
)
}

View File

@@ -8,13 +8,19 @@
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
// Type-only: pulls the shell's SlotMap merges (trigger/header/section/item).
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import type { ChromeInjected } from './chrome.tsx'
import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx'
import type { GeneralSectionInjected } from './GeneralSection.tsx'
import { GeneralSection } from './GeneralSection.tsx'
import type { WelcomeNoticeInjected } from './WelcomeNotice.tsx'
import { WelcomeNotice } from './WelcomeNotice.tsx'
import { refreshWelcomeIfLoaded, WelcomeNoticeStore } from './welcome-store.ts'
import { en, zh } from './locales.ts'
import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../onboarding-copy.ts'
export type {
ChromeInjected, CloseLabelProps, HeaderContentProps, TriggerContentProps,
@@ -22,6 +28,8 @@ export type {
export type {
GeneralSectionComponentProps, GeneralSectionInjected,
} from './GeneralSection.tsx'
export type { WelcomeNoticeInjected, WelcomeNoticeProps } from './WelcomeNotice.tsx'
export type { WelcomeNoticeState } from './welcome-store.ts'
/** Dictionary namespace owned by this plugin (shell chrome + General copy). */
const NS = 'settings'
@@ -31,7 +39,7 @@ const NS = 'settings'
* ui-settings' apply, whose activation order relative to this one is NOT
* constrained; registration goes through declaration-aware deferral.
*/
export const inject = ['slots', 'locale']
export const inject = ['slots', 'locale', 'connection']
/**
* Register the `settings` dictionaries, the chrome content, and the General
@@ -48,8 +56,28 @@ export function apply(ctx: ClientContext): void {
}, 'ui-settings-general: dictionaries')
const t = ctx.locale.bind(NS)
const connection = ctx.get('connection') as ConnectionHandle
const welcomeController = new WelcomeNoticeStore(connection.api)
const useWelcomeSnapshot = bindSnapshotSelector(welcomeController.store)
const chromeInjected = (): ChromeInjected => ({ t })
const generalInjected = (): GeneralSectionInjected => ({ t })
const welcomeInjected = (): WelcomeNoticeInjected => ({
controller: welcomeController,
useSnapshot: useWelcomeSnapshot,
t,
})
ctx.effect(() => {
const refresh = (ns?: string): void => {
if (ns !== undefined && ns !== WELCOME_NOTICE_SETTINGS_NAMESPACE) return
refreshWelcomeIfLoaded(welcomeController)
}
const disposers = [
ctx.on('settings/changed', refresh),
ctx.on('connection/reset', () => { refresh() }),
]
return () => { for (const dispose of disposers) dispose() }
}, 'ui-settings-general: welcome invalidations')
// All four seats refresh on locale change: re-registration bumps each
// slot's ledger version, which re-renders the outlets through their own
@@ -70,11 +98,19 @@ export function apply(ctx: ClientContext): void {
children: { 'settings.general.item': { kind: 'list', scope: 'root' } },
inject: generalInjected,
}, GeneralSection))
const welcome = deferRegistration(ctx.slots, 'settings.onboarding', WelcomeNotice, () =>
ctx.slots.register({
name: 'settings.onboarding',
id: 'welcome-notice',
order: -100,
inject: welcomeInjected,
}, WelcomeNotice))
const offLocale = ctx.on('locale/change', () => {
trigger.refresh()
header.refresh()
close.refresh()
general.refresh()
welcome.refresh()
})
return () => {
offLocale()
@@ -82,6 +118,7 @@ export function apply(ctx: ClientContext): void {
header.dispose()
close.dispose()
general.dispose()
welcome.dispose()
}
}, 'ui-settings-general: chrome and section registrations')
}, 'ui-settings-general: chrome, section, and onboarding registrations')
}

View File

@@ -6,6 +6,7 @@
* (Language, Appearance) ship their copy in their own packages.
*/
import type { LocaleDict } from '@deepseek-ai/dsh-client-locale/client'
import { WELCOME_NOTICE_COPY } from '../onboarding-copy.ts'
const SHARED = {
'permission.value': 'Read only',
@@ -25,6 +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.continue': WELCOME_NOTICE_COPY.zh.continueLabel,
'welcome.error': '暂时无法保存确认状态,请重试。',
}
/** English dictionary. */
@@ -37,4 +44,10 @@ 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.continue': WELCOME_NOTICE_COPY.en.continueLabel,
'welcome.error': 'The acknowledgement could not be saved. Please try again.',
}

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

@@ -1,4 +1,31 @@
/** Host loader entry for the browser implementation exported from `./client`. */
/** Host plugin body — no host-side behavior for the general settings plugin. */
export function apply(): void {}
import type { Context } from 'cordis'
import z from 'schemastery'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import {
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE,
} from './onboarding-copy.ts'
export {
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE,
WELCOME_NOTICE_VERSION,
} from './onboarding-copy.ts'
interface OnboardingSettings {
welcomeNoticeVersion?: string
}
const OnboardingSettingsSchema: z<OnboardingSettings> = z.object({
[WELCOME_NOTICE_ACK_FIELD]: z.string(),
})
/** Register the durable GUI-onboarding section when a settings provider exists. */
export function apply(ctx: Context): void {
ctx.inject(['settings'], (settingsCtx) => {
settingsCtx.settings.register(
settingsNamespace(WELCOME_NOTICE_SETTINGS_NAMESPACE),
OnboardingSettingsSchema,
)
})
}

View File

@@ -15,10 +15,9 @@ export const name = 'client-ui-settings-general-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: a copy-owning registrant contributing chrome content
* and the General section into shell-declared slots — it emits no cordis
* events and owns no cross-plugin mutable relation; slot conflicts already
* fail loud in the slot core at load time.
* No runtime invariant: the settings seam validates and publishes the durable
* welcome section, while slot conflicts fail loud in the slot core; this
* package owns no additional event/data relationship between those systems.
*/
const install: InvariantInstaller = () => {}

View File

@@ -0,0 +1,33 @@
/** 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.1'
/** The complete editable welcome notice in both supported GUI locales. */
export const WELCOME_NOTICE_COPY = {
zh: {
paragraphs: [
'感谢您愿意拨冗试用 DeepSeek Harness。',
'目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。',
'“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也可能促使我们重新审视,甚至推翻已有的设计。',
'我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至反而为工作平添了麻烦,请在企业微信群中留言,将使用感受告诉我们。每一条反馈,都会帮助我们把它打磨得更好。',
],
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.',
],
continueLabel: 'Continue',
},
} as const

View File

@@ -1,12 +1,15 @@
/** Ownerless-copy registrations: the four seats, the dictionaries, locale refresh, and HMR recovery. */
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client'
import type { GeneralSectionInjected } from '@deepseek-ai/dsh-client-ui-settings-general/client'
import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx'
import { GeneralSection } from '../src/client/GeneralSection.tsx'
import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx'
import type { WelcomeNoticeInjected } from '../src/client/WelcomeNotice.tsx'
import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../src/onboarding-copy.ts'
/** The four seats this plugin fills (slot name → expected component). */
const SEATS = [
@@ -14,6 +17,7 @@ const SEATS = [
['settings.header', HeaderContent],
['settings.close', CloseLabel],
['settings.section', GeneralSection],
['settings.onboarding', WelcomeNotice],
] as const
async function bench() {
@@ -21,7 +25,25 @@ async function bench() {
await ctx.plugin(SlotsService).await()
const locale = new LocaleService(ctx)
ctx.provide('locale', locale)
return { ctx, slots: ctx.get('slots') as SlotsService, locale }
const settingsDescribe = vi.fn(() => Promise.resolve({
rpcId: 'settings-general' as never,
result: {
ok: true as const,
value: {
writable: true,
namespaces: [{
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
schema: {},
value: {},
applies: 'live' as const,
secrets: [],
revision: 0,
}],
},
},
}))
ctx.provide('connection', { api: { settings: { describe: settingsDescribe } } } as never)
return { ctx, slots: ctx.get('slots') as SlotsService, locale, settingsDescribe }
}
/** Declare the shell's four child slots the way ui-settings' entry does. */
@@ -34,6 +56,7 @@ function declare(slots: SlotsService): () => void {
'settings.header': { kind: 'single', scope: 'root' },
'settings.close': { kind: 'single', scope: 'root' },
'settings.section': { kind: 'list', scope: 'root' },
'settings.onboarding': { kind: 'list', scope: 'root' },
},
} as never,
() => null,
@@ -46,7 +69,7 @@ function generalEntry(slots: SlotsService) {
describe('ui-settings-general apply', () => {
it('declares the services it uses', () => {
expect(inject).toEqual(['slots', 'locale'])
expect(inject).toEqual(['slots', 'locale', 'connection'])
})
it('fills all four seats for declarations before or after apply', async () => {
@@ -61,6 +84,8 @@ describe('ui-settings-general apply', () => {
expect(before.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
const injected = (entry.inject as unknown as () => GeneralSectionInjected)()
expect(injected.t('permission.title')).toBe('权限')
const welcome = before.slots.entries('settings.onboarding')[0]!
expect(welcome.options).toEqual({ id: 'welcome-notice', order: -100 })
// The chrome seats share one inject face: the settings-ns translate.
const chrome = (before.slots.entries('settings.trigger')[0]!.inject as unknown as () => GeneralSectionInjected)()
expect(chrome.t('trigger')).toBe('设置')
@@ -116,6 +141,22 @@ describe('ui-settings-general apply', () => {
b.locale.setLocale('zh')
})
it('refreshes loaded welcome state only for its settings namespace or a reconnect', async () => {
const b = await bench()
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const entry = b.slots.entries('settings.onboarding')[0]!
const { controller } = (entry.inject as unknown as () => WelcomeNoticeInjected)()
await controller.load()
expect(b.settingsDescribe).toHaveBeenCalledOnce()
b.ctx.emit('settings/changed', 'unrelated')
expect(b.settingsDescribe).toHaveBeenCalledOnce()
b.ctx.emit('settings/changed', WELCOME_NOTICE_SETTINGS_NAMESPACE)
await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(2) })
b.ctx.emit('connection/reset')
await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(3) })
})
it('re-registers after an HMR collapse of the declaring chain (stale disposers must not block)', async () => {
const b = await bench()
const redeclare = declare(b.slots)

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

@@ -9,10 +9,4 @@ describe('invariant companion', () => {
await ctx.plugin(InvariantService, { enabled: true })
await expect(ctx.plugin(GeneralInvariant).await()).resolves.toBeDefined()
})
it('node-half apply is a no-op host placeholder', async () => {
const { apply } = await import('@deepseek-ai/dsh-client-ui-settings-general')
apply()
expect(true).toBe(true) // reaching here without throw is the contract
})
})

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 => zh[key] ?? 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 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 buttons = dialog.querySelectorAll('button')
expect(buttons).toHaveLength(1)
expect(screen.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel })).toBeTruthy()
fireEvent.keyDown(document, { key: 'Escape' })
fireEvent.click(dialog.parentElement!.firstElementChild!)
expect(h.complete).not.toHaveBeenCalled()
expect(screen.getByRole('dialog')).toBeTruthy()
})
it('completes only after the acknowledgement write commits', async () => {
const h = mount()
await screen.findByRole('dialog')
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('dialog')).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('dialog')
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')
})
})

View File

@@ -14,6 +14,9 @@
{
"path": "../ui-slots"
},
{
"path": "../connection"
},
{
"path": "../ui-primitives"
},
@@ -23,9 +26,15 @@
{
"path": "../ui-settings"
},
{
"path": "../web-react"
},
{
"path": "../locale"
},
{
"path": "../../settings/settings"
},
{
"path": "../../support/invariants"
}