feat(web): add versioned first-run welcome
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
}
|
||||
|
||||
108
packages/client/ui-settings-general/src/client/welcome-store.ts
Normal file
108
packages/client/ui-settings-general/src/client/welcome-store.ts
Normal 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()
|
||||
}
|
||||
@@ -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 = () => {}
|
||||
|
||||
|
||||
33
packages/client/ui-settings-general/src/onboarding-copy.ts
Normal file
33
packages/client/ui-settings-general/src/onboarding-copy.ts
Normal 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
|
||||
Reference in New Issue
Block a user