fix(web): address onboarding review feedback

This commit is contained in:
Yichen Jiang
2026-07-30 18:56:56 +08:00
parent 234018032d
commit fc8f992cde
12 changed files with 168 additions and 87 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md
2026-07-30-deepseek-onboarding-credential-setup.md: 9249b173f8f6da5dc2abf2fb147a3c9aba99c00f
2026-07-30-deepseek-onboarding-credential-setup.zh.md: f3c647669bd9e0974b2c9f0c407eba0c600bc656
2026-07-30-deepseek-onboarding-credential-setup.md: 3f75a0893623afc0908cb48f2b838321ed9dedd3
2026-07-30-deepseek-onboarding-credential-setup.zh.md: 62f8f0b99f167b22051aaddf7331a043bd2ea812

View File

@@ -16,7 +16,7 @@ The [web configuration plane](../architecture/2026-07-30-web-config-plane.md) ma
**The prompt routes to the one credential editor.** A mounted, active adapter with a resolved, writable, unconfigured reference presents one action that opens Settings on Models. The existing DeepSeek setup card there exclusively owns the password input, `credentials.set({ref, value})`, write failures, and post-write refresh; the onboarding overlay never holds or submits a secret. An unavailable settings or credential capability keeps its deployment diagnostic and routes to the same page, while an absent adapter remains skipped because navigation cannot mount a Cordis plugin.
**Unavailable capability states stay honest.** An absent configurable-provider entry suppresses the form because it cannot repair the composition. A present provider whose settings or credential capability cannot be resolved renders an actionable deployment diagnostic. Cancel dismisses the overlay for the current mounted surface and writes no completion fact. Settings, credential, provider-topology, and connection invalidations all refresh the shared join, so an external credential update closes an open prompt without a reload.
**Unavailable states stay honest.** An absent configurable-provider entry suppresses the prompt because navigation cannot repair the composition. A present provider whose settings or credential capability cannot be resolved renders an actionable deployment diagnostic; a failed initial join names the connection problem and leads to the Models retry surface. Configure later dismisses the overlay for the current mounted surface and writes no completion fact. Settings, credential, provider-topology, and connection invalidations all refresh the shared join, so an external credential update closes an open prompt without a reload.
## Alternatives considered
@@ -26,7 +26,7 @@ The [web configuration plane](../architecture/2026-07-30-web-config-plane.md) ma
**Writing the API key into provider settings** — rejected because a literal secret would enter the settings mutation path and whole-section replacement cannot safely reconstruct redacted values. Credential storage is already the product seam and supplies immediate invalidation.
**Showing the same key form when `llm-deepseek` is absent** — rejected because success would only store an unused environment reference; the browser has no supported operation that mounts the missing Cordis plugin.
**Showing the prompt when `llm-deepseek` is absent** — rejected because browser navigation has no supported operation that mounts the missing Cordis plugin.
## Consequences

View File

@@ -16,7 +16,7 @@ Status: implemented
**浮层只负责跳转到唯一的凭据编辑器。**适配器已挂载且处于活跃状态,其引用可解析、可写但尚未配置时,界面会显示一个操作按钮,用于打开「设置」的 Models 分区。该分区已有的 DeepSeek 设置卡片全权负责密码输入框、`credentials.set({ref, value})`、写入失败处理和写入后刷新;首次使用浮层绝不持有或提交 secret。设置或凭据能力不可用时会保留部署诊断并提供前往同一页面的入口适配器缺失时仍直接跳过因为导航无法挂载 Cordis 插件。
**能力不可用如实呈现。**可配置提供方条目缺失时不显示表单,因为无法修复当前组合。提供方存在,但设置或凭据能力无法解析时,界面会显示可采取操作的部署诊断。取消只会在当前已挂载界面中关闭浮层,不写入任何完成状态。设置、凭据、提供方拓扑和连接失效事件都会刷新共享联接,因此外部凭据更新无需重新加载页面即可关闭已打开的浮层。
**不可用状态如实呈现。**可配置提供方条目缺失时不显示浮层,因为导航无法修复当前组合。提供方存在,但设置或凭据能力无法解析时,界面会显示可采取操作的部署诊断;初始联接失败时会明确指出连接问题,并引导前往 Models 的重试界面。「稍后配置」只会在当前已挂载界面中关闭浮层,不写入任何完成状态。设置、凭据、提供方拓扑和连接失效事件都会刷新共享联接,因此外部凭据更新无需重新加载页面即可关闭已打开的浮层。
## 曾考虑的替代方案
@@ -26,7 +26,7 @@ Status: implemented
**把 API key 写入提供方设置**:不予采用,因为字面量 secret 会进入设置变更路径,而整个分节替换无法安全重建脱敏值。凭据存储已经是产品 seam并能立即发出失效事件。
**`llm-deepseek` 缺失时仍显示同一个密钥表单**:不予采用,因为提交成功也只会存储一个无人使用的环境引用;浏览器没有任何受支持的操作可以挂载缺失的 Cordis 插件。
**`llm-deepseek` 缺失时仍显示浮层**:不予采用,因为浏览器导航没有任何受支持的操作可以挂载缺失的 Cordis 插件。
## 后果

View File

@@ -85,7 +85,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
it('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['missing.expected.md'])
})
})

View File

@@ -360,6 +360,7 @@ flowchart TD
pkg_client_ui_models --> pkg_client_connection
pkg_client_ui_models --> pkg_client_runtime
pkg_client_ui_models --> pkg_client_schema_form
pkg_client_ui_models --> pkg_client_ui_primitives
pkg_client_ui_models --> pkg_client_ui_slots
pkg_client_ui_models --> pkg_client_web_react
pkg_client_ui_models --> pkg_invariants
@@ -486,7 +487,6 @@ flowchart TD
pkg_sandbox_local --> pkg_llm
pkg_sandbox_local --> pkg_sandbox
pkg_sandbox_policy --> pkg_invariants
pkg_sandbox_policy --> pkg_paths
pkg_sandbox_policy --> pkg_sandbox
pkg_sandbox_policy --> pkg_session
pkg_session_projection --> pkg_invariants
@@ -1072,7 +1072,7 @@ flowchart TD
| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
| [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
| [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
| [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) |
| [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) |
| [`client-ui-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) |
| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
@@ -1105,7 +1105,7 @@ flowchart TD
| [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
| [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
| [`session-projection`](../packages/session-projection/session-projection) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) |

View File

@@ -1535,7 +1535,8 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
},
settings: {
// Only the resolved DeepSeek address needed by first-run readiness is
// represented here; real schema-driven forms ride the HTTP transport.
// represented here. Fixture-backed journeys do not open its Models
// editor; real schema-driven forms ride the HTTP transport.
describe: request => ok(request, {
writable: true,
namespaces: [{

View File

@@ -9,7 +9,7 @@ import type { ReactNode } from 'react'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { Button, 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 type { DeepSeekReadiness, ModelsSettingsState, ModelsSettingsStore } from './store.ts'
import { deepSeekReadiness } from './store.ts'
import type { en } from './locales.ts'
import styles from './DeepSeekOnboardingDialog.module.css'
@@ -28,6 +28,35 @@ export interface DeepSeekOnboardingInjected {
export type DeepSeekOnboardingDialogProps =
PropsRuntime<'settings.onboarding'> & DeepSeekOnboardingInjected
type UnavailableReason = Extract<DeepSeekReadiness, { kind: 'unavailable' }>['reason']
/* v8 ignore next 3 -- closed-union defaults only defend future source widening */
function assertNever(_value: never): never {
throw new Error('unexpected DeepSeek onboarding state')
}
function unavailableDiagnostic(
reason: UnavailableReason,
t: DeepSeekOnboardingInjected['t'],
): string {
switch (reason) {
case 'load-failed':
return t('onboardingLoadFailed')
case 'credentials-unavailable':
return t('onboardingCredentialsUnavailable')
case 'settings-read-only':
case 'credential-read-only':
return t('onboardingReadOnly')
case 'provider-inactive':
case 'settings-unavailable':
case 'credential-ref-unavailable':
return t('onboardingConfigurationUnavailable')
/* v8 ignore next -- every current unavailable reason is handled above */
default:
return assertNever(reason)
}
}
/**
* Prompt a first-run user to open Models while the official adapter exists
* and its effective credential is not configured.
@@ -53,13 +82,28 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps):
openSection('models')
}
if (!active || dismissed || readiness.kind === 'loading'
|| readiness.kind === 'adapter-absent' || readiness.kind === 'configured') return null
if (!active || dismissed) return null
const unavailable = readiness.kind === 'unavailable'
const diagnostic = unavailable && readiness.reason === 'credentials-unavailable'
? t('onboardingCredentialsUnavailable')
: t('onboardingConfigurationUnavailable')
let unavailableReason: UnavailableReason | undefined
switch (readiness.kind) {
case 'loading':
case 'adapter-absent':
case 'configured':
return null
case 'credential-missing':
unavailableReason = undefined
break
case 'unavailable':
unavailableReason = readiness.reason
break
/* v8 ignore next -- every current readiness variant is handled above */
default:
return assertNever(readiness)
}
const unavailable = unavailableReason !== undefined
const diagnostic = unavailableReason === undefined
? undefined
: unavailableDiagnostic(unavailableReason, t)
return (
<Modal
@@ -73,13 +117,14 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps):
<Button
variant="primary"
className={styles['primary']}
autoFocus
onClick={openModels}
>
{t('onboardingGoToSettings')}
</Button>
)}
>
{unavailable ? <p className={styles['diagnostic']}>{diagnostic}</p> : undefined}
{diagnostic === undefined ? undefined : <p className={styles['diagnostic']}>{diagnostic}</p>}
</Modal>
)
}

View File

@@ -32,8 +32,10 @@ export const en = {
onboardingGoToSettings: 'Go to settings',
onboardingLater: 'Configure later',
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.',
onboardingLoadFailed: 'DeepSeek configuration could not be loaded. Check the connection and try again in Models.',
onboardingCredentialsUnavailable: 'Credential storage is unavailable in this deployment. Check the deployment configuration.',
onboardingReadOnly: 'This deployment does not allow the DeepSeek API key to be changed here. Ask an administrator to provide the credential.',
onboardingConfigurationUnavailable: 'DeepSeek configuration is unavailable in this deployment. Check the deployment composition.',
}
/** Chinese strings (same keys as {@link en}). */
@@ -68,6 +70,8 @@ export const zh: typeof en = {
onboardingGoToSettings: '前往配置',
onboardingLater: '稍后配置',
onboardingUnavailableTitle: '无法在此配置 DeepSeek',
onboardingCredentialsUnavailable: '当前部署没有可写的凭据存储。请挂载 @deepseek-ai/dsh-credentials-local 后重试。',
onboardingConfigurationUnavailable: '无法在此解析 DeepSeek 的实时配置能力。请检查部署组合后重试。',
onboardingLoadFailed: '无法加载 DeepSeek 配置。请检查连接,然后在模型设置中重试。',
onboardingCredentialsUnavailable: '当前部署无法使用凭据存储。请检查部署配置。',
onboardingReadOnly: '当前部署不允许在此修改 DeepSeek API 密钥。请联系管理员提供凭据。',
onboardingConfigurationUnavailable: '当前部署无法使用 DeepSeek 配置。请检查部署组合。',
}

View File

@@ -180,17 +180,18 @@ export class ModelsSettingsStore {
export type DeepSeekReadiness =
| { kind: 'loading' }
| { kind: 'adapter-absent' }
| { kind: 'configured'; source: 'literal' | 'credential'; ref?: string; credential?: CredentialView }
| { kind: 'configured' }
| { kind: 'credential-missing' }
| {
kind: 'unavailable'
reason:
| 'load-failed'
| 'provider-inactive'
| 'settings-unavailable'
| 'credential-ref-unavailable'
| 'credentials-unavailable'
| 'settings-read-only'
| 'credential-read-only'
message: string
}
/**
@@ -207,8 +208,7 @@ export function deepSeekReadiness(state: ModelsSettingsState): DeepSeekReadiness
if (state.status === 'error') {
return {
kind: 'unavailable',
reason: 'settings-unavailable',
message: state.error ?? 'provider/settings describe failed',
reason: 'load-failed',
}
}
const row = state.rows.find(candidate => candidate.entry.provider === 'deepseek-official')
@@ -217,51 +217,46 @@ export function deepSeekReadiness(state: ModelsSettingsState): DeepSeekReadiness
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.literalApiKeyConfigured) return { kind: 'configured' }
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' }
}
if (!state.writable) {
return {
kind: 'configured',
source: 'credential',
ref: row.apiKeyEnv,
credential: row.credential,
kind: 'unavailable',
reason: 'settings-read-only',
}
}
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' }

View File

@@ -24,37 +24,53 @@ function fail<T>(message: string): RpcResponse<T> {
function harness(options: {
provider?: boolean
providerActive?: boolean
settingsNamespace?: boolean
apiKeyEnv?: string | null
literal?: boolean
configured?: () => boolean
credential?: { source?: string; writable: boolean }
describeFailure?: string
settingsWritable?: boolean
providersRejectOnce?: boolean
} = {}) {
let fileConfigured = false
let rejectProviders = options.providersRejectOnce === true
const configured = options.configured ?? (() => fileConfigured)
const face = {
llm: {
providers: () => Promise.resolve(ok({
providers: options.provider === false
? []
: [{
provider: 'deepseek-official',
displayName: 'DeepSeek',
settingsNs: 'llm-deepseek',
settingsPath: [],
active: true,
}],
})),
providers: () => {
if (rejectProviders) {
rejectProviders = false
return Promise.reject(new Error('provider transport unavailable'))
}
return Promise.resolve(ok({
providers: options.provider === false
? []
: [{
provider: 'deepseek-official',
displayName: 'DeepSeek',
settingsNs: 'llm-deepseek',
settingsPath: [],
active: options.providerActive ?? 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 }],
}],
writable: options.settingsWritable ?? true,
namespaces: options.settingsNamespace === false
? []
: [{
ns: 'llm-deepseek',
schema: {},
value: options.apiKeyEnv === null
? {}
: { apiKeyEnv: options.apiKeyEnv ?? 'DEEPSEEK_API_KEY' },
applies: 'live' as const,
secrets: [{ path: ['apiKey'], set: options.literal === true }],
}],
})),
},
credentials: {
@@ -94,7 +110,9 @@ describe('DeepSeekOnboardingDialog', () => {
render(<DeepSeekOnboardingDialog {...h.props} />)
expect(await screen.findByRole('dialog', { name: en.onboardingTitle })).toBeTruthy()
expect(screen.getByText(en.onboardingDescription)).toBeTruthy()
expect(screen.getByRole('button', { name: en.onboardingGoToSettings })).toBeTruthy()
const action = screen.getByRole('button', { name: en.onboardingGoToSettings })
expect(action).toBeTruthy()
expect(document.activeElement).toBe(action)
expect(screen.queryByRole('textbox')).toBeNull()
})
@@ -125,11 +143,38 @@ describe('DeepSeekOnboardingDialog', () => {
expect(h.openSection).toHaveBeenCalledWith('models')
})
it('uses the general diagnostic for a missing read-only credential', async () => {
const h = harness({ credential: { writable: false } })
it('explains read-only credential and settings deployments', async () => {
for (const h of [
harness({ credential: { writable: false } }),
harness({ settingsWritable: false }),
]) {
const view = render(<DeepSeekOnboardingDialog {...h.props} />)
await screen.findByRole('dialog', { name: en.onboardingUnavailableTitle })
expect(screen.getByText(en.onboardingReadOnly)).toBeTruthy()
view.unmount()
}
})
it('distinguishes an initial transport failure from deployment misconfiguration', async () => {
const h = harness({ providersRejectOnce: true })
render(<DeepSeekOnboardingDialog {...h.props} />)
await screen.findByRole('dialog', { name: en.onboardingUnavailableTitle })
expect(screen.getByText(en.onboardingConfigurationUnavailable)).toBeTruthy()
expect(screen.getByText(en.onboardingLoadFailed)).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: en.onboardingGoToSettings }))
expect(h.openSection).toHaveBeenCalledWith('models')
})
it('uses the configuration diagnostic for inactive or unresolvable adapters', async () => {
for (const h of [
harness({ providerActive: false }),
harness({ settingsNamespace: false }),
harness({ apiKeyEnv: null }),
]) {
const view = render(<DeepSeekOnboardingDialog {...h.props} />)
await screen.findByRole('dialog', { name: en.onboardingUnavailableTitle })
expect(screen.getByText(en.onboardingConfigurationUnavailable)).toBeTruthy()
view.unmount()
}
})
it('skips an absent adapter and already-configured literal or environment credentials', async () => {

View File

@@ -50,59 +50,48 @@ describe('deepSeekReadiness', () => {
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 },
})
}))).toEqual({ kind: 'configured' })
expect(deepSeekReadiness(state({
rows: [row({ credential: { configured: true, source: 'env', writable: false } })],
}))).toMatchObject({
kind: 'configured',
source: 'credential',
credential: { source: 'env', writable: false },
})
}))).toEqual({ kind: 'configured' })
})
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' })
}))).toEqual({ kind: 'configured' })
})
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',
reason: 'load-failed',
})
expect(deepSeekReadiness(state({
rows: [row({ entry: { ...row().entry, active: false } })],
}))).toMatchObject({ kind: 'unavailable', reason: 'provider-inactive' })
}))).toEqual({ kind: 'unavailable', reason: 'provider-inactive' })
expect(deepSeekReadiness(state({
rows: [row({ configured: false })],
}))).toMatchObject({ kind: 'unavailable', reason: 'settings-unavailable' })
}))).toEqual({ kind: 'unavailable', reason: 'settings-unavailable' })
expect(deepSeekReadiness(state({
rows: [row({ apiKeyEnv: undefined })],
}))).toMatchObject({ kind: 'unavailable', reason: 'credential-ref-unavailable' })
}))).toEqual({ kind: 'unavailable', reason: 'credential-ref-unavailable' })
expect(deepSeekReadiness(state({
credentialError: 'credentials service is absent',
}))).toMatchObject({
}))).toEqual({
kind: 'unavailable',
reason: 'credentials-unavailable',
message: 'credentials service is absent',
})
expect(deepSeekReadiness(state({
rows: [row({ credential: undefined })],
}))).toMatchObject({ kind: 'unavailable', reason: 'credentials-unavailable' })
}))).toEqual({ kind: 'unavailable', reason: 'credentials-unavailable' })
expect(deepSeekReadiness(state({
rows: [row({ credential: { configured: false, writable: false } })],
}))).toMatchObject({ kind: 'unavailable', reason: 'credential-read-only' })
}))).toEqual({ kind: 'unavailable', reason: 'credential-read-only' })
expect(deepSeekReadiness(state({ writable: false }))).toEqual({
kind: 'unavailable',
reason: 'settings-read-only',
})
})
})

View File

@@ -5,7 +5,9 @@
* close label, sections) arrives from registrants through slots; accessible
* names resolve to that content (trigger: its own text; dialog:
* aria-labelledby the title node; close: visually-hidden slot text). Modal
* open state and the active section id are component-local viewing state.
* open state and the active section id are component-local viewing state;
* the onboarding slot receives the sessions-derived empty-Hero fact and a
* private callback that opens one registered section.
*/
import { useCallback, useEffect, useId, useRef, useState } from 'react'
import clsx from 'clsx'