Browse Source

feat(web): configure DeepSeek during onboarding

Yichen Jiang 2 months ago
parent
commit
9182db00ef

+ 20 - 9
packages/client/connection/src/client/fixture.ts

@@ -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) => {

+ 30 - 0
packages/client/connection/tests/fixture.spec.ts

@@ -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
packages/client/ui-models/package.json

@@ -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:^",

+ 54 - 0
packages/client/ui-models/src/client/DeepSeekOnboardingDialog.module.css

@@ -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%;
+}

+ 186 - 0
packages/client/ui-models/src/client/DeepSeekOnboardingDialog.tsx

@@ -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>
+  )
+}

+ 1 - 1
packages/client/ui-models/src/client/ModelsSection.tsx

@@ -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
packages/client/ui-models/src/client/index.ts

@@ -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')
 }

+ 26 - 0
packages/client/ui-models/src/client/locales.ts

@@ -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
packages/client/ui-models/src/client/store.ts

@@ -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
packages/client/ui-models/tests/apply.spec.ts

@@ -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)
+  })
 })

+ 17 - 1
packages/client/ui-models/tests/components.spec.tsx

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

+ 265 - 0
packages/client/ui-models/tests/onboarding-dialog.spec.tsx

@@ -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('')
+  })
+})

+ 112 - 0
packages/client/ui-models/tests/readiness.spec.ts

@@ -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' })
+  })
+})

+ 48 - 0
packages/client/ui-models/tests/store.spec.ts

@@ -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 - 0
packages/client/ui-models/tsconfig.json

@@ -23,6 +23,9 @@
     {
       "path": "../schema-form"
     },
+    {
+      "path": "../ui-primitives"
+    },
     {
       "path": "../web-react"
     },

+ 4 - 2
packages/client/ui-primitives/src/Modal.tsx

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

+ 2 - 1
packages/client/ui-primitives/tests/atoms.spec.tsx

@@ -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
packages/client/ui-settings/package.json

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

+ 28 - 7
packages/client/ui-settings/src/client/SettingsRoot.tsx

@@ -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 })}
     </>
   )
 }

+ 18 - 1
packages/client/ui-settings/src/client/contract/slots.ts

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

+ 7 - 7
packages/client/ui-settings/src/client/index.ts

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

+ 4 - 3
packages/client/ui-settings/tests/apply.spec.ts

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

+ 26 - 3
packages/client/ui-settings/tests/settings-root.spec.tsx

@@ -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 - 0
pnpm-lock.yaml

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