feat(web): configure DeepSeek during onboarding
This commit is contained in:
@@ -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 }))
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 的实时配置能力。请检查部署组合后重试。',
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
{
|
||||
"path": "../schema-form"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../web-react"
|
||||
},
|
||||
|
||||
@@ -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,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()
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user