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

@@ -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