feat(web): configure DeepSeek during onboarding

This commit is contained in:
Yichen Jiang
2026-07-30 12:41:09 +08:00
parent 65bd54f8b4
commit 9182db00ef
24 changed files with 1051 additions and 53 deletions
@@ -588,7 +588,11 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
{ provider: 'deepseek-official', model: 'deepseek-v4-flash' },
]))
/** Credential store double: set/unset flip the describe badge, values never read back. */
const fixtureCredentials = new Map<string, string>()
const fixtureCredentials = new Map<string, true>([
// The assembled fixture represents an already-configured shipped
// DeepSeek route so unrelated GUI journeys do not enter first-run setup.
['DEEPSEEK_API_KEY', true],
])
const nextTurn = new Map<SessionId, number>([[sid('fx-alpha'), 60]])
let nextSession = 1
let nextRpc = 1
@@ -1284,19 +1288,26 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
},
},
settings: {
// The fixture registers no namespaces yet: the Models surface renders
// its provider list from llm.providers alone, and a real settings form
// rides the HTTP transport (a hand-written schema envelope here would
// drift from schemastery's real serialization).
describe: request => ok(request, { writable: true, namespaces: [] }),
// Only the resolved DeepSeek address needed by first-run readiness is
// represented here; real schema-driven forms ride the HTTP transport.
describe: request => ok(request, {
writable: true,
namespaces: [{
ns: 'llm-deepseek',
schema: {},
value: { apiKeyEnv: 'DEEPSEEK_API_KEY' },
applies: 'live',
secrets: [{ path: ['apiKey'], set: false }],
}],
}),
update: request => err(request, {
code: 'settings-rejected',
message: 'fixture: no settings namespaces are registered',
message: 'fixture: the minimal readiness settings descriptor is read-only',
details: { ns: request.payload.ns },
}),
replace: request => err(request, {
code: 'settings-rejected',
message: 'fixture: no settings namespaces are registered',
message: 'fixture: the minimal readiness settings descriptor is read-only',
details: { ns: request.payload.ns },
}),
},
@@ -1309,7 +1320,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
}])),
}),
set: (request) => {
fixtureCredentials.set(request.payload.ref, request.payload.value)
fixtureCredentials.set(request.payload.ref, true)
return ok(request, {})
},
unset: (request) => {
@@ -108,6 +108,36 @@ describe('createFixtureApi', () => {
expect(JSON.stringify(after.result.value.events)).toContain('openai/gpt-5')
})
it('serves configured DeepSeek readiness and keeps credential values write-only', async () => {
const api = createFixtureApi()
const settings = await api.settings.describe(req({}))
if (!settings.result.ok) throw new Error('settings describe failed')
expect(settings.result.value.namespaces).toMatchObject([{
ns: 'llm-deepseek',
value: { apiKeyEnv: 'DEEPSEEK_API_KEY' },
secrets: [{ path: ['apiKey'], set: false }],
}])
const initial = await api.credentials.describe(req({ refs: ['DEEPSEEK_API_KEY', 'TEST_API_KEY'] }))
if (!initial.result.ok) throw new Error('credential describe failed')
expect(initial.result.value.credentials).toEqual({
DEEPSEEK_API_KEY: { configured: true, source: 'file', writable: true },
TEST_API_KEY: { configured: false, writable: true },
})
await api.credentials.set(req({ ref: 'TEST_API_KEY', value: 'write-only-fixture-secret' }))
const configured = await api.credentials.describe(req({ refs: ['TEST_API_KEY'] }))
if (!configured.result.ok) throw new Error('credential describe failed')
expect(configured.result.value.credentials.TEST_API_KEY).toEqual({
configured: true,
source: 'file',
writable: true,
})
await api.credentials.unset(req({ ref: 'TEST_API_KEY' }))
const cleared = await api.credentials.describe(req({ refs: ['TEST_API_KEY'] }))
if (!cleared.result.ok) throw new Error('credential describe failed')
expect(cleared.result.value.credentials.TEST_API_KEY).toEqual({ configured: false, writable: true })
})
it('emits the todo/write snapshot at the real tool boundary: between tool/call and tool/result, timestamps monotonic', async () => {
const api = createFixtureApi()
const tail = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 10 }))
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-ui-models",
"description": "Models feature plugin: registers its Settings section (nav entry, empty content column; model management lands later)",
"description": "Models settings and official-DeepSeek first-run credential UI over one live provider/settings/credential join",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -39,6 +39,7 @@
"@deepseek-ai/dsh-client-connection": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-schema-form": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^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",
@@ -50,6 +51,7 @@
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-schema-form": "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:^",
@@ -0,0 +1,54 @@
.dialog {
width: min(420px, 100%);
}
.fields {
display: flex;
flex-direction: column;
gap: 14px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.label {
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-label-secondary);
}
.input {
width: 100%;
box-sizing: border-box;
}
.input > input {
width: 100%;
}
.advanced {
align-self: flex-start;
padding-inline: 0;
color: var(--dsw-alias-label-secondary);
}
.error {
margin: 0;
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-state-error-primary);
}
.diagnostic {
margin: 0;
font-size: 13px;
line-height: 20px;
color: var(--dsw-alias-label-secondary);
}
.primary {
width: 100%;
}
@@ -0,0 +1,186 @@
/**
* Official-DeepSeek first-run dialog. Readiness comes from the same
* provider/settings/credential join as the Models page; the component holds
* only the write-only draft and viewing state.
*/
import { useEffect, useState } from 'react'
import type { ReactNode } from 'react'
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { Button, Input, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import type { ModelsSettingsState, ModelsSettingsStore } from './store.ts'
import { deepSeekReadiness } from './store.ts'
import type { en } from './locales.ts'
import styles from './DeepSeekOnboardingDialog.module.css'
/** Injected dependencies of {@link DeepSeekOnboardingDialog}. */
export interface DeepSeekOnboardingInjected {
/** Shared Models-page join controller. */
controller: ModelsSettingsStore
/** Subscription hook bound to the shared join snapshot. */
useSnapshot: SnapshotSelectorHook<ModelsSettingsState>
/** Write-only credential wire face. */
credentials: IApiClient['credentials']
/** Feature copy. */
t: (key: keyof typeof en) => string
}
/** Slot owner props plus the feature's injected dependencies. */
export type DeepSeekOnboardingDialogProps =
PropsRuntime<'settings.onboarding'> & DeepSeekOnboardingInjected
/** Remove the submitted non-empty secret from any error text before it reaches the DOM. */
function redactSecret(message: string, secret: string): string {
return message.split(secret).join('[redacted]')
}
/**
* Render the first-run credential dialog while the official adapter exists
* and its effective reference is writable but unconfigured.
* @param props - settings-shell owner state and Models feature dependencies.
* @returns the controlled modal or null when onboarding needs no intervention.
*/
export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): ReactNode {
const { active, openSection, controller, useSnapshot, credentials, t } = props
const state = useSnapshot(snapshot => snapshot)
const readiness = deepSeekReadiness(state)
const [dismissed, setDismissed] = useState(false)
const [keyDraft, setKeyDraft] = useState('')
const [busy, setBusy] = useState(false)
const [failure, setFailure] = useState<string | undefined>(undefined)
useEffect(() => {
if (active && !dismissed && state.status === 'idle') void controller.load()
}, [active, controller, dismissed, state.status])
useEffect(() => {
if (!active || readiness.kind !== 'credential-missing') {
setKeyDraft('')
setFailure(undefined)
}
}, [active, readiness.kind, readiness.kind === 'credential-missing' ? readiness.ref : undefined])
const close = (): void => {
setKeyDraft('')
setFailure(undefined)
setDismissed(true)
}
const openModels = (): void => {
close()
openSection('models')
}
const save = async (): Promise<void> => {
/* v8 ignore next -- the form only attaches save while missing and disables it for an empty draft */
if (readiness.kind !== 'credential-missing' || keyDraft.length === 0) return
const secret = keyDraft
const ref = readiness.ref
setBusy(true)
setFailure(undefined)
try {
const response = await credentials.set({ ref, value: secret })
if (!response.result.ok) {
setFailure(`${t('onboardingSaveFailed')}: ${redactSecret(response.result.error.message, secret)}`)
return
}
await controller.load()
if (deepSeekReadiness(controller.store.getSnapshot()).kind !== 'configured') {
setFailure(t('onboardingVerifyFailed'))
return
}
setKeyDraft('')
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
setFailure(`${t('onboardingSaveFailed')}: ${redactSecret(message, secret)}`)
} finally {
setBusy(false)
}
}
const retry = async (): Promise<void> => {
setBusy(true)
try {
await controller.load()
} finally {
setBusy(false)
}
}
if (!active || dismissed || readiness.kind === 'loading'
|| readiness.kind === 'adapter-absent' || readiness.kind === 'configured') return null
const unavailable = readiness.kind === 'unavailable'
const diagnostic = unavailable && readiness.reason === 'credentials-unavailable'
? t('onboardingCredentialsUnavailable')
: t('onboardingConfigurationUnavailable')
const displayName = readiness.kind === 'credential-missing'
? readiness.displayName
: 'DeepSeek'
return (
<Modal
open
onClose={close}
title={unavailable ? t('onboardingUnavailableTitle') : t('onboardingTitle')}
closeLabel={t('onboardingLater')}
{...(unavailable ? {} : { description: t('onboardingDescription') })}
className={styles['dialog'] as string}
footer={(
<Button
variant="primary"
className={styles['primary']}
disabled={busy || (!unavailable && keyDraft.length === 0)}
onClick={() => { void (unavailable ? retry() : save()) }}
>
{busy
? t('onboardingSaving')
: unavailable
? t('retry')
: t('onboardingSave')}
</Button>
)}
>
<div className={styles['fields']}>
<label className={styles['field']}>
<span className={styles['label']}>{t('provider')}</span>
<Input
className={styles['input'] as string}
type="text"
aria-label={t('provider')}
value={displayName}
readOnly
/>
</label>
{readiness.kind === 'credential-missing'
? (
<label className={styles['field']}>
<span className={styles['label']}>{t('onboardingKey')}</span>
<Input
className={styles['input'] as string}
type="password"
autoComplete="off"
autoCapitalize="none"
spellCheck={false}
aria-label={t('onboardingKey')}
placeholder={t('onboardingKeyPlaceholder')}
value={keyDraft}
disabled={busy}
onChange={(event) => {
setKeyDraft(event.target.value)
setFailure(undefined)
}}
/>
</label>
)
: <p className={styles['diagnostic']}>{diagnostic}</p>}
<Button variant="ghost" size="sm" className={styles['advanced']} onClick={openModels}>
{t('onboardingAdvanced')}
</Button>
{failure !== undefined ? <p className={styles['error']} role="alert">{failure}</p> : null}
</div>
</Modal>
)
}
@@ -68,7 +68,7 @@ function StatusBadges({ row, t }: { row: ProviderRow; t: ModelsSectionInjected['
{row.entry.active
? <span className={styles['badgeOk']}>{t('active')}</span>
: <span className={styles['badgeMuted']}>{t('dormant')}</span>}
{row.credential !== undefined && !row.credential.configured
{!row.literalApiKeyConfigured && row.credential !== undefined && !row.credential.configured
? <span className={styles['badgeWarn']}>{t('keyMissing')}</span>
: null}
</span>
+32 -9
View File
@@ -1,9 +1,9 @@
/**
* Models settings section plugin, browser half. Registers the `models` nav
* entry into the shell-declared `settings.section` list slot and mounts the
* provider configuration page: the configurable-provider directory joined
* with settings namespaces and credential states, edited through the
* schema-driven form. Export discipline: packages/client/AGENTS.md.
* Models settings plugin, browser half. Registers the `models` nav entry and
* official-DeepSeek first-run overlay into shell-declared slots. Both consume
* one provider/settings/credential join; the full page edits through the
* schema-driven form while onboarding exposes only write-only credential
* setup. Export discipline: packages/client/AGENTS.md.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
@@ -15,6 +15,8 @@ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import type {} from '@deepseek-ai/dsh-client-locale/client'
import { ModelsSection } from './ModelsSection.tsx'
import type { ModelsSectionInjected } from './ModelsSection.tsx'
import { DeepSeekOnboardingDialog } from './DeepSeekOnboardingDialog.tsx'
import type { DeepSeekOnboardingInjected } from './DeepSeekOnboardingDialog.tsx'
import { ModelsSettingsStore } from './store.ts'
import { en, zh } from './locales.ts'
@@ -63,6 +65,12 @@ export function apply(ctx: ClientContext): void {
api: connection.api,
t,
})
const onboardingInjected = (): DeepSeekOnboardingInjected => ({
controller,
useSnapshot,
credentials: connection.api.credentials,
t,
})
// Pushed invalidations converge every open surface without polling: any
// settings/credentials/topology change refetches once the page loaded.
@@ -78,7 +86,7 @@ export function apply(ctx: ClientContext): void {
}, 'ui-models: pushed invalidations')
ctx.effect(() => {
const deferred = deferRegistration(ctx.slots, 'settings.section', ModelsSection, () =>
const section = deferRegistration(ctx.slots, 'settings.section', ModelsSection, () =>
ctx.slots.register({
name: 'settings.section',
id: 'models',
@@ -86,12 +94,27 @@ export function apply(ctx: ClientContext): void {
label: t('nav'),
inject: injected,
}, ModelsSection))
const onboarding = deferRegistration(
ctx.slots,
'settings.onboarding',
DeepSeekOnboardingDialog,
() => ctx.slots.register({
name: 'settings.onboarding',
id: 'deepseek-official',
order: 0,
inject: onboardingInjected,
}, DeepSeekOnboardingDialog),
)
// Nav labels are registrant-localized: refresh on locale change so the
// ledger carries fresh text (the version bump re-renders the shell).
const offLocale = ctx.on('locale/change', () => { deferred.refresh() })
const offLocale = ctx.on('locale/change', () => {
section.refresh()
onboarding.refresh()
})
return () => {
offLocale()
deferred.dispose()
section.dispose()
onboarding.dispose()
}
}, 'ui-models: settings section registration')
}, 'ui-models: settings registrations')
}
@@ -33,6 +33,19 @@ export const en = {
secretUnset: 'Not configured',
inherited: 'Default',
unsupported: 'This field has no form control; edit the settings document directly.',
onboardingTitle: 'Add a DeepSeek API key',
onboardingDescription: 'Configure the official DeepSeek provider to start building.',
onboardingKey: 'API key',
onboardingKeyPlaceholder: 'Enter your DeepSeek API key',
onboardingAdvanced: 'Advanced model settings',
onboardingSave: 'Save and continue',
onboardingSaving: 'Saving…',
onboardingLater: 'Configure later',
onboardingSaveFailed: 'Could not save the API key',
onboardingVerifyFailed: 'The key was saved, but its configured state could not be verified. Try again.',
onboardingUnavailableTitle: 'DeepSeek setup is unavailable',
onboardingCredentialsUnavailable: 'This deployment does not expose writable credential storage. Mount @deepseek-ai/dsh-credentials-local, then retry.',
onboardingConfigurationUnavailable: 'The live DeepSeek configuration capability cannot be resolved here. Check the deployment composition, then retry.',
}
/** Chinese strings (same keys as {@link en}). */
@@ -68,4 +81,17 @@ export const zh: typeof en = {
secretUnset: '未设置',
inherited: '默认',
unsupported: '该字段没有对应表单控件;请直接编辑设置文档。',
onboardingTitle: '添加 DeepSeek API 密钥',
onboardingDescription: '配置 DeepSeek 官方模型,即可开始使用。',
onboardingKey: 'API 密钥',
onboardingKeyPlaceholder: '输入 DeepSeek API 密钥',
onboardingAdvanced: '模型高级设置',
onboardingSave: '保存并继续',
onboardingSaving: '保存中…',
onboardingLater: '稍后配置',
onboardingSaveFailed: '无法保存 API 密钥',
onboardingVerifyFailed: '密钥已写入,但无法确认配置状态。请重试。',
onboardingUnavailableTitle: '无法在此配置 DeepSeek',
onboardingCredentialsUnavailable: '当前部署没有可写的凭据存储。请挂载 @deepseek-ai/dsh-credentials-local 后重试。',
onboardingConfigurationUnavailable: '无法在此解析 DeepSeek 的实时配置能力。请检查部署组合后重试。',
}
+130 -5
View File
@@ -25,6 +25,8 @@ export interface ProviderRow {
apiKeyEnv: string | undefined
/** Credential state for {@link apiKeyEnv}, once described. */
credential: CredentialView | undefined
/** Whether the redacted secret sidecar reports an effective literal `apiKey`. */
literalApiKeyConfigured: boolean
}
/** Page snapshot. */
@@ -32,6 +34,8 @@ export interface ModelsSettingsState {
status: 'idle' | 'loading' | 'ready' | 'error'
/** Whole-load failure text; row-level write failures stay in the editor. */
error: string | null
/** Credential enrichment failure; provider/settings rows remain usable. */
credentialError: string | null
/** Whether the settings provider accepts writes. */
writable: boolean
/** Every configurable provider joined with its configured/credential state. */
@@ -49,11 +53,29 @@ function apiKeyEnvOf(namespace: SettingsNamespaceView | undefined, path: readonl
return typeof ref === 'string' && ref.length > 0 ? ref : undefined
}
/** Whether one namespace's redacted sidecar reports a set literal API key. */
function literalApiKeyConfigured(
namespace: SettingsNamespaceView | undefined,
path: readonly string[],
): boolean {
if (namespace === undefined) return false
const secretPath = [...path, 'apiKey']
return namespace.secrets.some(secret =>
secret.set
&& secret.path.length === secretPath.length
&& secret.path.every((key, index) => key === secretPath[index]))
}
/** Safe display text for a rejected transport or business response. */
function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
/** The models settings page controller (one per settings surface). */
export class ModelsSettingsStore {
/** The snapshot the section renders from (uSES-safe store). */
readonly store: SnapshotStore<ModelsSettingsState> = createSnapshotStore<ModelsSettingsState>({
status: 'idle', error: null, writable: false, rows: [], namespaces: new Map(),
status: 'idle', error: null, credentialError: null, writable: false, rows: [], namespaces: new Map(),
})
/** Latest load wins; an older response never overwrites a newer one. */
@@ -109,20 +131,28 @@ export class ModelsSettingsStore {
removable,
apiKeyEnv: apiKeyEnvOf(namespace, entry.settingsPath),
credential: undefined,
literalApiKeyConfigured: literalApiKeyConfigured(namespace, entry.settingsPath),
}
})
const refs = [...new Set(rows.flatMap(row => row.apiKeyEnv === undefined ? [] : [row.apiKeyEnv]))]
let credentials: Record<string, CredentialView> = {}
let credentialError: string | null = null
if (refs.length > 0) {
const response = await this.api.credentials.describe({ refs })
// Credential state is an enrichment: rows render without it, so a
// missing credential provider degrades the badge, not the page.
if (response.result.ok) credentials = response.result.value.credentials
try {
const response = await this.api.credentials.describe({ refs })
// Credential state is an enrichment for the Models page, while the
// onboarding readiness projection below reports its failure.
if (response.result.ok) credentials = response.result.value.credentials
else credentialError = response.result.error.message
} catch (error) {
credentialError = errorText(error)
}
}
if (generation !== this.generation) return
this.store.update((s) => {
s.status = 'ready'
s.error = null
s.credentialError = credentialError
s.writable = writable
s.rows = rows.map(row => ({
...row,
@@ -134,3 +164,98 @@ export class ModelsSettingsStore {
})
}
}
/** DeepSeek onboarding readiness derived only from the shared Models join. */
export type DeepSeekReadiness =
| { kind: 'loading' }
| { kind: 'adapter-absent' }
| { kind: 'configured'; source: 'literal' | 'credential'; ref?: string; credential?: CredentialView }
| { kind: 'credential-missing'; displayName: string; ref: string }
| {
kind: 'unavailable'
reason:
| 'provider-inactive'
| 'settings-unavailable'
| 'credential-ref-unavailable'
| 'credentials-unavailable'
| 'credential-read-only'
message: string
}
/**
* Project official-DeepSeek readiness from the provider/settings/credential
* join used by the Models page. A missing directory entry means the adapter
* is not mounted and therefore cannot be repaired by a key form.
* @param state - current shared Models join snapshot.
* @returns the onboarding state without reading a parallel fact source.
*/
export function deepSeekReadiness(state: ModelsSettingsState): DeepSeekReadiness {
if ((state.status === 'idle' || state.status === 'loading') && state.rows.length === 0) {
return { kind: 'loading' }
}
if (state.status === 'error') {
return {
kind: 'unavailable',
reason: 'settings-unavailable',
message: state.error ?? 'provider/settings describe failed',
}
}
const row = state.rows.find(candidate => candidate.entry.provider === 'deepseek-official')
if (row === undefined) return { kind: 'adapter-absent' }
if (!row.entry.active) {
return {
kind: 'unavailable',
reason: 'provider-inactive',
message: 'the deepseek-official route is not active',
}
}
if (!row.configured) {
return {
kind: 'unavailable',
reason: 'settings-unavailable',
message: `settings namespace "${row.entry.settingsNs}" did not resolve the provider profile`,
}
}
if (row.literalApiKeyConfigured) return { kind: 'configured', source: 'literal' }
if (row.apiKeyEnv === undefined) {
return {
kind: 'unavailable',
reason: 'credential-ref-unavailable',
message: 'the resolved DeepSeek settings do not name an apiKeyEnv credential reference',
}
}
if (state.credentialError !== null) {
return {
kind: 'unavailable',
reason: 'credentials-unavailable',
message: state.credentialError,
}
}
if (row.credential === undefined) {
return {
kind: 'unavailable',
reason: 'credentials-unavailable',
message: `credential reference "${row.apiKeyEnv}" was not described`,
}
}
if (row.credential.configured) {
return {
kind: 'configured',
source: 'credential',
ref: row.apiKeyEnv,
credential: row.credential,
}
}
if (!row.credential.writable) {
return {
kind: 'unavailable',
reason: 'credential-read-only',
message: `credential reference "${row.apiKeyEnv}" is missing and read-only`,
}
}
return {
kind: 'credential-missing',
displayName: row.entry.displayName,
ref: row.apiKeyEnv,
}
}
+31 -2
View File
@@ -1,10 +1,11 @@
/** Models section registration: declaration-aware deferral, locale re-registration, 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, refreshIfLoaded } from '@deepseek-ai/dsh-client-ui-models/client'
import { ModelsSection } from '../src/client/ModelsSection.tsx'
import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx'
async function bench() {
const ctx = new Context()
@@ -19,7 +20,13 @@ async function bench() {
function declare(slots: SlotsService): () => void {
return slots.register(
{ name: 'root', children: { 'settings.section': { kind: 'list', scope: 'root' } } } as never,
{
name: 'root',
children: {
'settings.section': { kind: 'list', scope: 'root' },
'settings.onboarding': { kind: 'list', scope: 'root' },
},
} as never,
() => null,
)
}
@@ -41,13 +48,18 @@ describe('ui-models apply', () => {
expect(typeof injected.controller.load).toBe('function')
expect(typeof injected.useSnapshot).toBe('function')
expect(injected.api).toBeDefined()
const onboarding = before.slots.entries('settings.onboarding')[0]!
expect(onboarding.component).toBe(DeepSeekOnboardingDialog)
expect(onboarding.options).toMatchObject({ id: 'deepseek-official', order: 0 })
const after = await bench()
await after.ctx.plugin({ inject: [...inject], apply }).await()
expect(after.slots.entries('settings.section')).toHaveLength(0)
expect(after.slots.entries('settings.onboarding')).toHaveLength(0)
declare(after.slots)
await Promise.resolve()
expect(after.slots.entries('settings.section')[0]!.component).toBe(ModelsSection)
expect(after.slots.entries('settings.onboarding')[0]!.component).toBe(DeepSeekOnboardingDialog)
// The self-inflicted ledger notifications hit the duplicate guard.
expect(after.slots.entries('settings.section')).toHaveLength(1)
})
@@ -79,9 +91,11 @@ describe('ui-models apply', () => {
// disposer variable goes stale.
redeclare()
expect(b.slots.entries('settings.section')).toHaveLength(0)
expect(b.slots.entries('settings.onboarding')).toHaveLength(0)
declare(b.slots)
await Promise.resolve()
expect(b.slots.entries('settings.section')[0]!.component).toBe(ModelsSection)
expect(b.slots.entries('settings.onboarding')[0]!.component).toBe(DeepSeekOnboardingDialog)
// The locale path also recovers through the same ledger re-check.
b.locale.setLocale('en')
expect(b.slots.entries('settings.section')[0]!.options.label).toBe('Models')
@@ -96,6 +110,7 @@ describe('ui-models apply', () => {
expect(b.locale.bind('settings.models')('nav')).toBe('模型')
await fiber.dispose()
expect(b.slots.entries('settings.section')).toHaveLength(0)
expect(b.slots.entries('settings.onboarding')).toHaveLength(0)
// The (ns, locale) seats are free again — the dictionary disposers ran.
expect(() => b.locale.register('settings.models', 'zh', {})).not.toThrow()
expect(() => b.locale.register('settings.models', 'en', {})).not.toThrow()
@@ -129,4 +144,18 @@ describe('pushed invalidations', () => {
refreshIfLoaded(idle as unknown as import('../src/client/store.ts').ModelsSettingsStore)
expect(loads).toHaveLength(1)
})
it('routes pushed credential invalidation into the shared onboarding join', async () => {
const b = await bench()
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const injected = (
b.slots.entries('settings.onboarding')[0]!.inject as unknown as
() => import('../src/client/DeepSeekOnboardingDialog.tsx').DeepSeekOnboardingInjected
)()
injected.controller.store.update((state) => { state.status = 'ready' })
const load = vi.spyOn(injected.controller, 'load').mockResolvedValue()
b.ctx.emit('credentials/changed', 'DEEPSEEK_API_KEY')
expect(load).toHaveBeenCalledTimes(1)
})
})
@@ -6,7 +6,7 @@ import Schema from 'schemastery'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
import { ModelsSection, removeProviderProfile } from '../src/client/ModelsSection.tsx'
import type { ModelsSectionInjected } from '../src/client/ModelsSection.tsx'
import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx'
import { ModelsSettingsStore } from '../src/client/store.ts'
import { en } from '../src/client/locales.ts'
@@ -121,6 +121,12 @@ async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {})
}
describe('ModelsSection', () => {
it('renders nothing before the slot injects its dependencies', () => {
const uninjected = {} as ModelsSectionProps
render(<ModelsSection {...uninjected} />)
expect(document.body.textContent).toBe('')
})
it('renders configured rows with status badges and the add vocabulary', async () => {
await mountSection()
expect(screen.getByText('DeepSeek')).toBeTruthy()
@@ -135,6 +141,16 @@ describe('ModelsSection', () => {
expect(screen.getAllByText(en.remove)).toHaveLength(2)
})
it('does not mark a provider with a configured literal key as missing', async () => {
const { controller } = await mountSection()
controller.store.update((state) => {
state.rows = state.rows.map(row => row.entry.provider === 'deepseek-official'
? { ...row, literalApiKeyConfigured: true }
: row)
})
await waitFor(() => { expect(screen.queryByText(en.keyMissing)).toBeNull() })
})
it('opens the editor, applies an edit as a merge patch, and reloads', async () => {
const { update, face } = await mountSection()
fireEvent.click(screen.getAllByText(en.edit)[1] as HTMLElement)
@@ -0,0 +1,265 @@
// @vitest-environment jsdom
/** First-run DeepSeek dialog behavior over the shared Models join. */
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { RpcResponse } from '@deepseek-ai/dsh-client-connection/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx'
import type { DeepSeekOnboardingDialogProps } from '../src/client/DeepSeekOnboardingDialog.tsx'
import { ModelsSettingsStore } from '../src/client/store.ts'
import { en } from '../src/client/locales.ts'
afterEach(cleanup)
let nextRpc = 0
function ok<T>(value: T): RpcResponse<T> {
return { rpcId: `onboarding-${nextRpc++}` as never, result: { ok: true, value } }
}
function fail<T>(message: string): RpcResponse<T> {
return {
rpcId: `onboarding-${nextRpc++}` as never,
result: { ok: false, error: { code: 'internal', message, details: {} } },
}
}
function harness(options: {
provider?: boolean
literal?: boolean
configured?: () => boolean
credential?: { source?: string; writable: boolean }
describeFailure?: string
set?: (payload: { ref: string; value: string }) => Promise<RpcResponse<{}>>
} = {}) {
let fileConfigured = false
const configured = options.configured ?? (() => fileConfigured)
const set = vi.fn(options.set ?? ((payload: { ref: string; value: string }) => {
fileConfigured = payload.value.length > 0
return Promise.resolve(ok({}))
}))
const face = {
llm: {
providers: () => Promise.resolve(ok({
providers: options.provider === false
? []
: [{
provider: 'deepseek-official',
displayName: 'DeepSeek',
settingsNs: 'llm-deepseek',
settingsPath: [],
active: true,
}],
})),
},
settings: {
describe: () => Promise.resolve(ok({
writable: true,
namespaces: [{
ns: 'llm-deepseek',
schema: {},
value: { apiKeyEnv: 'DEEPSEEK_API_KEY' },
applies: 'live' as const,
secrets: [{ path: ['apiKey'], set: options.literal === true }],
}],
})),
},
credentials: {
describe: () => options.describeFailure === undefined
? Promise.resolve(ok({
credentials: {
DEEPSEEK_API_KEY: {
configured: configured(),
...configured() && options.credential?.source !== undefined
? { source: options.credential.source }
: {},
writable: options.credential?.writable ?? true,
},
},
}))
: Promise.resolve(fail(options.describeFailure)),
set,
},
}
const controller = new ModelsSettingsStore(face as never)
const openSection = vi.fn()
const unusedHook = (() => { throw new Error('unused standard hook') }) as never
const props: DeepSeekOnboardingDialogProps = {
active: true,
openSection,
useSessions: unusedHook,
useWorkspaces: unusedHook,
controller,
useSnapshot: bindSnapshotSelector(controller.store),
credentials: face.credentials as never,
t: key => en[key],
}
return { controller, face, openSection, props, set, configure: () => { fileConfigured = true } }
}
describe('DeepSeekOnboardingDialog', () => {
it('loads on first entry and presents an accessible write-only key form', async () => {
const h = harness()
render(<DeepSeekOnboardingDialog {...h.props} />)
const dialog = await screen.findByRole('dialog', { name: en.onboardingTitle })
expect(dialog).toBeTruthy()
expect(screen.getByLabelText<HTMLInputElement>(en.provider).value).toBe('DeepSeek')
const key = screen.getByLabelText<HTMLInputElement>(en.onboardingKey)
expect(key.type).toBe('password')
expect(key.autocomplete).toBe('off')
expect(key.getAttribute('spellcheck')).toBe('false')
})
it('stores through credentials.set, verifies through describe, clears the draft, and closes', async () => {
const h = harness()
render(<DeepSeekOnboardingDialog {...h.props} />)
const key = await screen.findByLabelText<HTMLInputElement>(en.onboardingKey)
const secret = 'test-onboarding-secret'
fireEvent.change(key, { target: { value: secret } })
fireEvent.click(screen.getByRole('button', { name: en.onboardingSave }))
await waitFor(() => { expect(screen.queryByRole('dialog')).toBeNull() })
expect(h.set).toHaveBeenCalledWith({ ref: 'DEEPSEEK_API_KEY', value: secret })
expect(document.body.textContent).not.toContain(secret)
expect(document.documentElement.outerHTML).not.toContain(secret)
})
it('keeps a business failure open without echoing the secret', async () => {
const secret = 'business-secret'
const h = harness({
set: payload => Promise.resolve(fail(`refused ${payload.value}`)),
})
render(<DeepSeekOnboardingDialog {...h.props} />)
const key = await screen.findByLabelText<HTMLInputElement>(en.onboardingKey)
fireEvent.change(key, { target: { value: secret } })
fireEvent.click(screen.getByRole('button', { name: en.onboardingSave }))
const alert = await screen.findByRole('alert')
expect(alert.textContent).toContain('[redacted]')
expect(alert.textContent).not.toContain(secret)
expect(screen.getByRole<HTMLButtonElement>('button', { name: en.onboardingSave }).disabled).toBe(false)
expect(screen.getByRole('dialog')).toBeTruthy()
fireEvent.change(key, { target: { value: 'replacement' } })
expect(screen.queryByRole('alert')).toBeNull()
})
it('shows saving state and reports a failed configured-state verification', async () => {
let settle: (() => void) | undefined
const pending = new Promise<void>((resolve) => { settle = resolve })
const h = harness({
set: async () => {
await pending
return ok({})
},
})
render(<DeepSeekOnboardingDialog {...h.props} />)
fireEvent.change(await screen.findByLabelText(en.onboardingKey), { target: { value: 'verify-secret' } })
fireEvent.click(screen.getByRole('button', { name: en.onboardingSave }))
expect(screen.getByRole('button', { name: en.onboardingSaving })).toBeTruthy()
settle?.()
expect((await screen.findByRole('alert')).textContent).toBe(en.onboardingVerifyFailed)
expect(screen.getByRole<HTMLButtonElement>('button', { name: en.onboardingSave }).disabled).toBe(false)
})
it('recovers busy state after a transport rejection without an unhandled rejection', async () => {
const secret = 'transport-secret'
const h = harness({
set: () => Promise.reject(new Error(`transport rejected ${secret}`)),
})
const unhandled = vi.fn()
window.addEventListener('unhandledrejection', unhandled)
try {
render(<DeepSeekOnboardingDialog {...h.props} />)
const key = await screen.findByLabelText<HTMLInputElement>(en.onboardingKey)
fireEvent.change(key, { target: { value: secret } })
fireEvent.click(screen.getByRole('button', { name: en.onboardingSave }))
const alert = await screen.findByRole('alert')
expect(alert.textContent).not.toContain(secret)
expect(screen.getByRole<HTMLButtonElement>('button', { name: en.onboardingSave }).disabled).toBe(false)
expect(unhandled).not.toHaveBeenCalled()
} finally {
window.removeEventListener('unhandledrejection', unhandled)
}
})
it('stringifies a non-Error transport rejection without exposing its secret', async () => {
const secret = 'plain-rejection-secret'
const h = harness({
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
set: () => Promise.reject(`transport refused ${secret}`),
})
render(<DeepSeekOnboardingDialog {...h.props} />)
fireEvent.change(await screen.findByLabelText(en.onboardingKey), { target: { value: secret } })
fireEvent.click(screen.getByRole('button', { name: en.onboardingSave }))
const alert = await screen.findByRole('alert')
expect(alert.textContent).toContain('[redacted]')
expect(alert.textContent).not.toContain(secret)
})
it('cancels without writing and opens the Models section through the owner callback', async () => {
const cancelled = harness()
const first = render(<DeepSeekOnboardingDialog {...cancelled.props} />)
await screen.findByRole('dialog')
fireEvent.click(screen.getByRole('button', { name: en.onboardingLater }))
expect(screen.queryByRole('dialog')).toBeNull()
expect(cancelled.set).not.toHaveBeenCalled()
first.unmount()
const advanced = harness()
render(<DeepSeekOnboardingDialog {...advanced.props} />)
await screen.findByRole('dialog')
fireEvent.click(screen.getByRole('button', { name: en.onboardingAdvanced }))
expect(advanced.openSection).toHaveBeenCalledWith('models')
expect(screen.queryByRole('dialog')).toBeNull()
expect(advanced.set).not.toHaveBeenCalled()
})
it('shows an actionable deployment diagnostic when credentials are unavailable', async () => {
const h = harness({ describeFailure: 'credentials service is absent' })
render(<DeepSeekOnboardingDialog {...h.props} />)
await screen.findByRole('dialog', { name: en.onboardingUnavailableTitle })
expect(screen.getByText(en.onboardingCredentialsUnavailable)).toBeTruthy()
expect(screen.queryByLabelText(en.onboardingKey)).toBeNull()
fireEvent.click(screen.getByRole('button', { name: en.retry }))
await waitFor(() => {
expect(screen.getByRole<HTMLButtonElement>('button', { name: en.retry }).disabled).toBe(false)
})
})
it('uses the deployment diagnostic for a missing read-only credential', async () => {
const h = harness({ credential: { writable: false } })
render(<DeepSeekOnboardingDialog {...h.props} />)
await screen.findByRole('dialog', { name: en.onboardingUnavailableTitle })
expect(screen.getByText(en.onboardingConfigurationUnavailable)).toBeTruthy()
expect(screen.queryByLabelText(en.onboardingKey)).toBeNull()
})
it('skips an absent adapter and already-configured literal or environment credentials', async () => {
for (const h of [
harness({ provider: false }),
harness({ literal: true, describeFailure: 'credential seam absent' }),
harness({ configured: () => true, credential: { source: 'env', writable: false } }),
]) {
const view = render(<DeepSeekOnboardingDialog {...h.props} />)
await act(async () => { await h.controller.load() })
expect(screen.queryByRole('dialog')).toBeNull()
view.unmount()
}
})
it('closes when an external credential invalidation refreshes the shared join', async () => {
const h = harness()
render(<DeepSeekOnboardingDialog {...h.props} />)
await screen.findByRole('dialog')
h.configure()
await act(async () => { await h.controller.load() })
await waitFor(() => { expect(screen.queryByRole('dialog')).toBeNull() })
})
it('clears a typed draft when the onboarding owner becomes inactive', async () => {
const h = harness()
const view = render(<DeepSeekOnboardingDialog {...h.props} />)
const key = await screen.findByLabelText<HTMLInputElement>(en.onboardingKey)
fireEvent.change(key, { target: { value: 'ephemeral' } })
view.rerender(<DeepSeekOnboardingDialog {...h.props} active={false} />)
expect(screen.queryByRole('dialog')).toBeNull()
view.rerender(<DeepSeekOnboardingDialog {...h.props} active />)
expect((await screen.findByLabelText<HTMLInputElement>(en.onboardingKey)).value).toBe('')
})
})
@@ -0,0 +1,112 @@
/** Pure official-DeepSeek readiness projection over the shared Models join. */
import { describe, expect, it } from 'vitest'
import type { CredentialView } from '@deepseek-ai/dsh-client-connection/client'
import type { ModelsSettingsState, ProviderRow } from '../src/client/store.ts'
import { deepSeekReadiness } from '../src/client/store.ts'
const missingCredential: CredentialView = { configured: false, writable: true }
function row(overrides: Partial<ProviderRow> = {}): ProviderRow {
return {
entry: {
provider: 'deepseek-official',
displayName: 'DeepSeek',
settingsNs: 'llm-deepseek',
settingsPath: [],
active: true,
},
configured: true,
removable: false,
apiKeyEnv: 'DEEPSEEK_API_KEY',
credential: missingCredential,
literalApiKeyConfigured: false,
...overrides,
}
}
function state(overrides: Partial<ModelsSettingsState> = {}): ModelsSettingsState {
return {
status: 'ready',
error: null,
credentialError: null,
writable: true,
rows: [row()],
namespaces: new Map(),
...overrides,
}
}
describe('deepSeekReadiness', () => {
it('waits for the first join and skips onboarding when the adapter directory entry is absent', () => {
expect(deepSeekReadiness(state({ status: 'idle', rows: [] }))).toEqual({ kind: 'loading' })
expect(deepSeekReadiness(state({ status: 'loading', rows: [] }))).toEqual({ kind: 'loading' })
expect(deepSeekReadiness(state({ rows: [] }))).toEqual({ kind: 'adapter-absent' })
})
it('addresses the effective credential reference when it is missing and writable', () => {
expect(deepSeekReadiness(state())).toEqual({
kind: 'credential-missing',
displayName: 'DeepSeek',
ref: 'DEEPSEEK_API_KEY',
})
})
it('accepts file and process-environment credentials without prompting', () => {
expect(deepSeekReadiness(state({
rows: [row({ credential: { configured: true, source: 'file', writable: true } })],
}))).toMatchObject({
kind: 'configured',
source: 'credential',
ref: 'DEEPSEEK_API_KEY',
credential: { source: 'file', writable: true },
})
expect(deepSeekReadiness(state({
rows: [row({ credential: { configured: true, source: 'env', writable: false } })],
}))).toMatchObject({
kind: 'configured',
source: 'credential',
credential: { source: 'env', writable: false },
})
})
it('accepts the redacted literal-key sidecar before judging the credential domain', () => {
expect(deepSeekReadiness(state({
credentialError: 'credentials service absent',
rows: [row({ literalApiKeyConfigured: true, credential: undefined })],
}))).toEqual({ kind: 'configured', source: 'literal' })
})
it('turns missing capabilities and inconsistent descriptors into diagnostics', () => {
expect(deepSeekReadiness(state({ status: 'error', error: 'settings down' }))).toEqual({
kind: 'unavailable',
reason: 'settings-unavailable',
message: 'settings down',
})
expect(deepSeekReadiness(state({ status: 'error', error: null }))).toMatchObject({
kind: 'unavailable',
reason: 'settings-unavailable',
})
expect(deepSeekReadiness(state({
rows: [row({ entry: { ...row().entry, active: false } })],
}))).toMatchObject({ kind: 'unavailable', reason: 'provider-inactive' })
expect(deepSeekReadiness(state({
rows: [row({ configured: false })],
}))).toMatchObject({ kind: 'unavailable', reason: 'settings-unavailable' })
expect(deepSeekReadiness(state({
rows: [row({ apiKeyEnv: undefined })],
}))).toMatchObject({ kind: 'unavailable', reason: 'credential-ref-unavailable' })
expect(deepSeekReadiness(state({
credentialError: 'credentials service is absent',
}))).toMatchObject({
kind: 'unavailable',
reason: 'credentials-unavailable',
message: 'credentials service is absent',
})
expect(deepSeekReadiness(state({
rows: [row({ credential: undefined })],
}))).toMatchObject({ kind: 'unavailable', reason: 'credentials-unavailable' })
expect(deepSeekReadiness(state({
rows: [row({ credential: { configured: false, writable: false } })],
}))).toMatchObject({ kind: 'unavailable', reason: 'credential-read-only' })
})
})
@@ -75,6 +75,7 @@ describe('ModelsSettingsStore', () => {
const state = store.store.getSnapshot()
expect(state.status).toBe('ready')
expect(state.writable).toBe(true)
expect(state.credentialError).toBeNull()
expect(seenRefs).toEqual([['DEEPSEEK_API_KEY', 'OPENAI_API_KEY']])
const byProvider = new Map(state.rows.map(row => [row.entry.provider, row]))
expect(byProvider.get('deepseek-official')).toMatchObject({
@@ -82,6 +83,7 @@ describe('ModelsSettingsStore', () => {
removable: false,
apiKeyEnv: 'DEEPSEEK_API_KEY',
credential: { configured: false, writable: true },
literalApiKeyConfigured: false,
})
expect(byProvider.get('openai')).toMatchObject({
configured: true,
@@ -101,9 +103,55 @@ describe('ModelsSettingsStore', () => {
await store.load()
const state = store.store.getSnapshot()
expect(state.status).toBe('ready')
expect(state.credentialError).toBe('no provider')
expect(state.rows.every(row => row.credential === undefined)).toBe(true)
})
it('settles a credential transport rejection without leaving the store loading', async () => {
const { face } = api({
describeCredentials: () => Promise.reject(new Error('credential transport down')),
})
const store = new ModelsSettingsStore(face)
await expect(store.load()).resolves.toBeUndefined()
expect(store.store.getSnapshot()).toMatchObject({
status: 'ready',
credentialError: 'credential transport down',
})
})
it('stringifies a non-Error credential transport rejection', async () => {
const { face } = api({
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
describeCredentials: () => Promise.reject('credential transport refusal'),
})
const store = new ModelsSettingsStore(face)
await expect(store.load()).resolves.toBeUndefined()
expect(store.store.getSnapshot().credentialError).toBe('credential transport refusal')
})
it('joins a configured literal key from the redacted secret sidecar', async () => {
const { face } = api({
describeSettings: () => Promise.resolve(ok({
writable: true,
namespaces: [{
...NAMESPACES[0],
secrets: [
{ path: ['apiKey', 'nested'], set: true },
{ path: ['different'], set: true },
{ path: ['apiKey'], set: true },
],
}] as never,
})),
providers: () => Promise.resolve(ok({ providers: [DIRECTORY[0]] as never })),
})
const store = new ModelsSettingsStore(face)
await store.load()
expect(store.store.getSnapshot().rows[0]).toMatchObject({
literalApiKeyConfigured: true,
apiKeyEnv: 'DEEPSEEK_API_KEY',
})
})
it('surfaces a directory failure and keeps the last good rows', async () => {
const { face } = api()
const store = new ModelsSettingsStore(face)
+3
View File
@@ -23,6 +23,9 @@
{
"path": "../schema-form"
},
{
"path": "../ui-primitives"
},
{
"path": "../web-react"
},
+4 -2
View File
@@ -13,15 +13,17 @@ import css from './Modal.module.css'
* @param props.open - whether the dialog is showing.
* @param props.onClose - Escape or mask click.
* @param props.title - dialog heading.
* @param props.closeLabel - accessible close-button label.
* @param props.description - optional supporting sentence under the title.
* @param props.children - body (inputs, etc.).
* @param props.footer - action row (Cancel / Create).
* @returns null when closed; otherwise the overlay tree.
*/
export function Modal({ open, onClose, title, description, children, footer, className }: {
export function Modal({ open, onClose, title, closeLabel = 'Close', description, children, footer, className }: {
open: boolean
onClose: () => void
title: string
closeLabel?: string
description?: string
children?: ReactNode
footer?: ReactNode
@@ -50,7 +52,7 @@ export function Modal({ open, onClose, title, description, children, footer, cla
<div className={css.content}>
<div className={css.header}>
<h2 className={css.title}>{title}</h2>
<button type="button" className={css.close} aria-label="Close" onClick={onClose}>
<button type="button" className={css.close} aria-label={closeLabel} onClick={onClose}>
<IconCloseOutline16 size={14} />
</button>
</div>
@@ -322,10 +322,11 @@ describe('Modal', () => {
<Modal open={false} onClose={onClose} title="Create new workspace">body</Modal>)
expect(screen.queryByRole('dialog')).toBeNull()
rerender(
<Modal open onClose={onClose} title="Create new workspace" description="Name it." footer={<button type="button">Create</button>}>
<Modal open onClose={onClose} title="Create new workspace" closeLabel="Configure later" description="Name it." footer={<button type="button">Create</button>}>
<input aria-label="name" />
</Modal>)
expect(screen.getByRole('dialog', { name: 'Create new workspace' })).toBeDefined()
expect(screen.getByRole('button', { name: 'Configure later' })).toBeDefined()
expect(screen.getByText('Name it.')).toBeDefined()
fireEvent.keyDown(document, { key: 'a' })
expect(onClose).not.toHaveBeenCalled()
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-ui-settings",
"description": "Settings shell plugin: sidebar trigger + modal panel occupying sidebar.settings; declares the settings.section list slot",
"description": "Settings shell plugin: sidebar trigger, modal panel, feature sections, and root-scoped onboarding overlays",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -22,6 +22,8 @@ function navIcon(id: string) {
type PanelProps = {
rows: readonly SettingsSectionRow[]
renderSlot: SettingsRootComponentProps['renderSlot']
activeId: string | undefined
onSelect: (id: string) => void
onClose: () => void
}
@@ -30,10 +32,9 @@ type PanelProps = {
* header button, a mask click, and document-level Escape (mounted only while
* open, so the listener lifetime is the panel's).
*/
function SettingsPanel({ rows, renderSlot, onClose }: PanelProps) {
// Local selection; entries can unmount underneath it, so the render-time
function SettingsPanel({ rows, renderSlot, activeId, onSelect, onClose }: PanelProps) {
// Entries can unmount underneath the requested id, so the render-time
// projection falls back to the first row when the id is gone.
const [activeId, setActiveId] = useState<string | undefined>(undefined)
const active = rows.find(r => r.id === activeId)?.id ?? rows[0]?.id
const titleId = useId()
@@ -62,7 +63,7 @@ function SettingsPanel({ rows, renderSlot, onClose }: PanelProps) {
type="button"
className={clsx(css.navCell, row.id === active && css.active)}
aria-current={row.id === active ? 'true' : undefined}
onClick={() => { setActiveId(row.id) }}
onClick={() => { onSelect(row.id) }}
>
{navIcon(row.id)}
<span className={css.navLabel}>{row.label}</span>
@@ -92,14 +93,25 @@ function SettingsPanel({ rows, renderSlot, onClose }: PanelProps) {
* @returns the settings shell element tree.
*/
export function SettingsRoot(props: SettingsRootComponentProps) {
const { wide, useSections, renderSlot } = props
const { wide, useSections, useSessions, renderSlot } = props
const [open, setOpen] = useState(false)
const close = useCallback(() => { setOpen(false) }, [])
const [activeId, setActiveId] = useState<string | undefined>(undefined)
const close = useCallback(() => {
setOpen(false)
setActiveId(undefined)
}, [])
const openSection = useCallback((id: string) => {
setActiveId(id)
setOpen(true)
}, [])
// The ledger tick keeps the nav rows fresh: registrants re-register with
// freshly localized text on locale change, and the trigger/header/close
// seats re-render through their own outlets' subscriptions.
const rows = useSections(s => s)
const onboardingActive = useSessions(state =>
state.phase === 'ready'
&& (state.current === undefined || state.byId[state.current]?.blank === true))
return (
<>
@@ -112,7 +124,16 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
>
{renderSlot('settings.trigger', { wide })}
</button>
{open && <SettingsPanel rows={rows} renderSlot={renderSlot} onClose={close} />}
{open && (
<SettingsPanel
rows={rows}
renderSlot={renderSlot}
activeId={activeId}
onSelect={setActiveId}
onClose={close}
/>
)}
{renderSlot('settings.onboarding', { active: onboardingActive, openSection })}
</>
)
}
@@ -47,6 +47,13 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* item registrant; the shell neither declares nor renders it.)
*/
'settings.section': { kind: 'list'; scope: 'root'; owner: SettingsSectionOwnerProps }
/**
* Root-scoped onboarding overlays contributed by settings features. The
* shell supplies whether the current navigation state is the empty Hero
* and a private callback that opens one settings section; registrants own
* readiness, copy, and dialog behavior.
*/
'settings.onboarding': { kind: 'list'; scope: 'root'; owner: SettingsOnboardingOwnerProps }
}
}
@@ -72,6 +79,14 @@ export interface SettingsSectionOwnerProps {
children?: never
}
/** Owner share of a settings-backed onboarding overlay. */
export interface SettingsOnboardingOwnerProps {
/** Whether the current UI is in its empty Hero/onboarding state. */
active: boolean
/** Open the settings panel directly on one registered section. */
openSection: (id: string) => void
}
/** One nav row projected from a settings.section registration's options. */
export interface SettingsSectionRow {
id: string
@@ -99,5 +114,7 @@ export type SettingsRootInjected = {
*/
export type SettingsRootComponentProps =
PropsRuntime<'sidebar.settings'>
& PropsRenderSlots<'settings.trigger' | 'settings.header' | 'settings.close' | 'settings.section'>
& PropsRenderSlots<
'settings.trigger' | 'settings.header' | 'settings.close' | 'settings.section' | 'settings.onboarding'
>
& InjectFace<SettingsRootInjected>
@@ -1,12 +1,11 @@
/**
* Settings shell plugin, browser half. A pure composition face: occupies the
* sidebar-owned `sidebar.settings` hole with the trigger chrome + modal
* panel, declares the `settings.trigger` / `settings.header` /
* `settings.section` slots, and projects the section ledger into the panel
* navigation. The shell ships no copy and reads no locale state — all text
* arrives from registrants (ui-settings-general owns the chrome and General
* content; features own their rows and sections). Export discipline:
* packages/client/AGENTS.md.
* panel, declares its chrome, section, and onboarding slots, and projects the
* section ledger into panel navigation. The shell ships no copy and reads no
* locale state — all text arrives from registrants (ui-settings-general owns
* the chrome and General content; features own their rows, sections, and
* onboarding overlays). Export discipline: packages/client/AGENTS.md.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
@@ -15,7 +14,7 @@ import { SettingsRoot } from './SettingsRoot.tsx'
export type {
SettingsHeaderOwnerProps, SettingsRootComponentProps, SettingsRootInjected,
SettingsSectionOwnerProps, SettingsSectionRow, SettingsTriggerOwnerProps,
SettingsOnboardingOwnerProps, SettingsSectionOwnerProps, SettingsSectionRow, SettingsTriggerOwnerProps,
} from './contract/slots.ts'
/**
@@ -67,6 +66,7 @@ export function apply(ctx: ClientContext): 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' },
},
inject: injected,
}, SettingsRoot))
@@ -24,12 +24,13 @@ function injectedOf(slots: SlotsService): SettingsRootInjected {
return (entry.inject as () => SettingsRootInjected)()
}
/** The shell's four child declarations (chrome seats + the section list). */
/** The shell's five child declarations (chrome, sections, and onboarding overlays). */
const CHILD_SPECS = {
'settings.trigger': { kind: 'single', scope: 'root' },
'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 const
describe('ui-settings apply', () => {
@@ -37,7 +38,7 @@ describe('ui-settings apply', () => {
expect(inject).toEqual(['slots'])
})
it('registers the shell and declares the four child slots, before or after the declaration', async () => {
it('registers the shell and declares the five child slots, before or after the declaration', async () => {
const before = await bench()
declare(before.slots)
await before.ctx.plugin({ inject: [...inject], apply }).await()
@@ -100,7 +101,7 @@ describe('ui-settings apply', () => {
}
})
it('unregisters the shell and collapses all four child slots on teardown', async () => {
it('unregisters the shell and collapses all five child slots on teardown', async () => {
const b = await bench()
declare(b.slots)
const fiber = b.ctx.plugin({ inject: [...inject], apply })
@@ -18,11 +18,12 @@ const SEAT_CONTENT: Record<string, string> = {
function mount({
wide = true,
onboardingActive = true,
rows = [
{ id: 'general', order: 0, label: 'General' },
{ id: 'models', order: 10, label: 'Models' },
],
}: { wide?: boolean; rows?: Row[] } = {}) {
}: { wide?: boolean; onboardingActive?: boolean; rows?: Row[] } = {}) {
// Mutable row source standing in for the bound useSections hook; bump()
// plays a ledger change through the same observable contract.
let current = rows
@@ -33,10 +34,16 @@ function mount({
return SEAT_CONTENT[key]
}) as SettingsRootComponentProps['renderSlot'],
)
// Global standard kit stubs: the shell consumes neither hook.
const useSessions = ((select: (state: unknown) => unknown) => select(onboardingActive
? { phase: 'ready', current: undefined, byId: {} }
: {
phase: 'ready',
current: 'active-session',
byId: { 'active-session': { blank: false } },
})) as never
const unusedHook = (() => { throw new Error('unused by SettingsRoot') }) as never
const props: SettingsRootComponentProps = {
useSessions: unusedHook,
useSessions,
useWorkspaces: unusedHook,
wide,
useSections: (select) => {
@@ -157,6 +164,22 @@ describe('SettingsPanel navigation', () => {
expect(screen.queryByTestId('section-general')).toBeNull()
})
it('hands Hero readiness and a direct section opener to onboarding registrants', () => {
const { renderSlot } = mount()
const onboardingCall = renderSlot.mock.calls.find(call => call[0] === 'settings.onboarding')
expect(onboardingCall?.[1]).toMatchObject({ active: true })
act(() => {
(onboardingCall?.[1] as { openSection: (id: string) => void }).openSection('models')
})
expect(screen.getByRole('dialog')).toBeTruthy()
expect(screen.getByTestId('section-models')).toBeTruthy()
cleanup()
const active = mount({ onboardingActive: false }).renderSlot.mock.calls
.find(call => call[0] === 'settings.onboarding')
expect(active?.[1]).toMatchObject({ active: false })
})
it('falls back to the first row when the active entry unregisters', () => {
const { bump } = mount()
openPanel()
+3
View File
@@ -1212,6 +1212,9 @@ importers:
'@deepseek-ai/dsh-client-schema-form':
specifier: workspace:^
version: link:../schema-form
'@deepseek-ai/dsh-client-ui-primitives':
specifier: workspace:^
version: link:../ui-primitives
'@deepseek-ai/dsh-client-ui-settings':
specifier: workspace:^
version: link:../ui-settings