Merge remote-tracking branch 'origin/master' into worktree/locale-browser-default
# Conflicts: # apps/web/tests/onboarding-deepseek-config.e2e.ts # packages/client/ui-settings-general/tests/apply.spec.ts
This commit is contained in:
@@ -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-settings-general/README.md
|
||||
README.md: 241678567c4dbc7411ab9e76f595f2f696cc02d6
|
||||
README.zh.md: da4568d109c20bf1860fb8841942d42078b9443a
|
||||
README.md: 3e191b501e69062b671df0f237f2128a4ad086d1
|
||||
README.zh.md: 44ba3eba8bfc756a7d68e43a3d34056349f7eaaa
|
||||
|
||||
@@ -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 and its `settings.general.item` slot, and the `settings` dictionaries. Feature-owned rows (Permission, 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 and its `settings.general.item` slot, the `settings` dictionaries, and the first ordered welcome step. Feature-owned rows (Permission, 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 page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -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、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-settings-general",
|
||||
"description": "Settings ownerless-copy plugin: the General section, shell trigger/header chrome content, and settings dictionaries",
|
||||
"description": "Settings ownerless-copy and product onboarding plugin: the General section, shell trigger/header chrome content, settings 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,23 +36,31 @@
|
||||
"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-test-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",
|
||||
|
||||
@@ -7,12 +7,18 @@
|
||||
*/
|
||||
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'
|
||||
// Type-only: pulls ctx.locale and the 'settings.general.item' SlotMap merge.
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { CloseLabel, HeaderContent, TriggerContent } from './chrome.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 { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../onboarding-copy.ts'
|
||||
import { en, zh, type SettingsKey } from './locales.ts'
|
||||
|
||||
export type {
|
||||
@@ -21,6 +27,8 @@ export type {
|
||||
export type {
|
||||
GeneralSectionComponentProps,
|
||||
} from './GeneralSection.tsx'
|
||||
export type { WelcomeNoticeInjected, WelcomeNoticeProps } from './WelcomeNotice.tsx'
|
||||
export type { WelcomeNoticeState } from './welcome-store.ts'
|
||||
export type { SettingsKey } from './locales.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
@@ -38,7 +46,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
|
||||
@@ -52,6 +60,25 @@ export function apply(ctx: ClientContext): void {
|
||||
// seat, and the nav label is a thunk the owner resolves per render — no
|
||||
// locale/change re-registration wiring.
|
||||
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 welcomeInjected = (): WelcomeNoticeInjected => ({
|
||||
controller: welcomeController,
|
||||
useSnapshot: useWelcomeSnapshot,
|
||||
})
|
||||
|
||||
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')
|
||||
ctx.effect(() => {
|
||||
const trigger = deferRegistration(ctx.slots, 'settings.trigger', TriggerContent, () =>
|
||||
ctx.slots.register({ name: 'settings.trigger', locale: NS }, TriggerContent))
|
||||
@@ -68,11 +95,20 @@ export function apply(ctx: ClientContext): void {
|
||||
locale: NS,
|
||||
children: { 'settings.general.item': { kind: 'list', scope: 'root' } },
|
||||
}, GeneralSection))
|
||||
const welcome = deferRegistration(ctx.slots, 'settings.onboarding', WelcomeNotice, () =>
|
||||
ctx.slots.register({
|
||||
name: 'settings.onboarding',
|
||||
id: 'welcome-notice',
|
||||
order: -100,
|
||||
locale: NS,
|
||||
inject: welcomeInjected,
|
||||
}, WelcomeNotice))
|
||||
return () => {
|
||||
trigger.dispose()
|
||||
header.dispose()
|
||||
close.dispose()
|
||||
general.dispose()
|
||||
welcome.dispose()
|
||||
}
|
||||
}, 'ui-settings-general: chrome and section registrations')
|
||||
}, 'ui-settings-general: chrome, section, and onboarding registrations')
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** Shell chrome and General-nav dictionaries; feature rows own their copy. */
|
||||
/** Shell chrome, General-nav, and welcome-notice dictionaries; feature rows own their copy. */
|
||||
import { WELCOME_NOTICE_COPY } from '../onboarding-copy.ts'
|
||||
|
||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||
export const zh = {
|
||||
@@ -6,6 +7,14 @@ export const zh = {
|
||||
'title': '设置',
|
||||
'close': '关闭',
|
||||
'general.nav': '通用设置',
|
||||
'welcome.title': WELCOME_NOTICE_COPY.zh.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.feedbackEmphasis': WELCOME_NOTICE_COPY.zh.feedbackEmphasis,
|
||||
'welcome.continue': WELCOME_NOTICE_COPY.zh.continueLabel,
|
||||
'welcome.error': '暂时无法保存确认状态,请重试。',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** The settings namespace key union. */
|
||||
@@ -17,4 +26,12 @@ export const en = {
|
||||
'title': 'Settings',
|
||||
'close': 'Close',
|
||||
'general.nav': 'General',
|
||||
'welcome.title': WELCOME_NOTICE_COPY.en.title,
|
||||
'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.feedbackEmphasis': WELCOME_NOTICE_COPY.en.feedbackEmphasis,
|
||||
'welcome.continue': WELCOME_NOTICE_COPY.en.continueLabel,
|
||||
'welcome.error': 'The acknowledgement could not be saved. Please try again.',
|
||||
} satisfies Record<SettingsKey, string>
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 = () => {}
|
||||
|
||||
|
||||
@@ -8,17 +8,21 @@ import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply, inject } 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 service reads its initial locale from the browser; these specs assert
|
||||
// the shipped Chinese copy, so they state the browser they assume.
|
||||
usePinnedBrowserLanguages('zh-CN')
|
||||
|
||||
/** The four seats this plugin fills (slot name → expected component). */
|
||||
/** The five seats this plugin fills (slot name → expected component). */
|
||||
const SEATS = [
|
||||
['settings.trigger', TriggerContent],
|
||||
['settings.header', HeaderContent],
|
||||
['settings.close', CloseLabel],
|
||||
['settings.section', GeneralSection],
|
||||
['settings.onboarding', WelcomeNotice],
|
||||
] as const
|
||||
|
||||
async function bench() {
|
||||
@@ -26,7 +30,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. */
|
||||
@@ -39,6 +61,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,
|
||||
@@ -51,10 +74,10 @@ 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 () => {
|
||||
it('fills all five seats for declarations before or after apply', async () => {
|
||||
const before = await bench()
|
||||
declare(before.slots)
|
||||
await before.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
@@ -67,6 +90,8 @@ describe('ui-settings-general apply', () => {
|
||||
expect(resolveSlotLabel(entry.options.label)).toBe('通用设置')
|
||||
expect(before.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
|
||||
expect(before.slots.entries('settings.general.item')).toEqual([])
|
||||
const welcome = before.slots.entries('settings.onboarding')[0]!
|
||||
expect(welcome.options).toMatchObject({ id: 'welcome-notice', order: -100 })
|
||||
// Copy rides the standard locale seat: every seat declares the namespace.
|
||||
for (const [name] of SEATS) {
|
||||
expect(before.slots.entries(name)[0]!.locale).toBe('settings')
|
||||
@@ -118,6 +143,22 @@ describe('ui-settings-general apply', () => {
|
||||
expect(resolveSlotLabel(generalEntry(b.slots)!.options.label)).toBe('通用设置')
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user