fix(web): end first-run onboarding on any usable provider
The step and the Models page both asked one question of a join that describes every provider: is deepseek-official's credential stored? A user who configured some other route was taken over on every blank session, and the DeepSeek setup card opened over them on every visit to Models with a Cancel that could not close it — while clearing the add card's draft, because it shared the row-editor close handler. providerUsable(row) now answers what both surfaces need: the route is registered and whatever credential its profile names is stored. Readiness (renamed onboardingReadiness) ends on any usable row, needsSetup takes the same fact, and each card kind owns its own close handler. Fixes #2325
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* Official-DeepSeek first-run step. Readiness comes from the same
|
||||
* provider/settings/credential join as the Models page; the prompt only
|
||||
* routes the user to that page's single credential editor.
|
||||
* provider/settings/credential join as the Models page: any provider the user
|
||||
* can already talk to ends the step, and only a user with none is offered the
|
||||
* official DeepSeek route. The prompt itself only routes to that page's single
|
||||
* credential editor.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
@@ -10,7 +12,7 @@ import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { BrandWordmark, Button, OnboardingSurface } 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 { onboardingReadiness } from './store.ts'
|
||||
import type { en } from './locales.ts'
|
||||
import styles from './DeepSeekOnboardingDialog.module.css'
|
||||
|
||||
@@ -34,15 +36,15 @@ function assertNever(_value: never): never {
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt a first-run user to open Models while the official adapter exists
|
||||
* and its effective credential is not configured.
|
||||
* Prompt a first-run user to open Models while no provider can serve requests
|
||||
* and the official adapter exists with an unconfigured effective credential.
|
||||
* @param props - settings-shell owner state and Models feature dependencies.
|
||||
* @returns the onboarding page or null when onboarding needs no intervention.
|
||||
*/
|
||||
export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): ReactNode {
|
||||
const { complete, openSection, controller, useSnapshot, t } = props
|
||||
const state = useSnapshot(snapshot => snapshot)
|
||||
const readiness = deepSeekReadiness(state)
|
||||
const readiness = onboardingReadiness(state)
|
||||
const titleRef = useRef<HTMLHeadingElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -52,7 +54,7 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps):
|
||||
useEffect(() => {
|
||||
if (
|
||||
readiness.kind === 'adapter-absent'
|
||||
|| readiness.kind === 'configured'
|
||||
|| readiness.kind === 'provider-ready'
|
||||
|| readiness.kind === 'unavailable'
|
||||
) complete()
|
||||
}, [complete, readiness.kind])
|
||||
@@ -72,7 +74,7 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps):
|
||||
switch (readiness.kind) {
|
||||
case 'loading':
|
||||
case 'adapter-absent':
|
||||
case 'configured':
|
||||
case 'provider-ready':
|
||||
case 'unavailable':
|
||||
return null
|
||||
case 'credential-missing':
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
* directory, settings namespaces, and credential states, with one editor
|
||||
* card at a time. Rows expose only confirmed API-key state through accessible
|
||||
* solid configured or missing dots. A whole-section provider without a
|
||||
* configured key (the unconfigured DeepSeek posture) renders as its open setup
|
||||
* card instead of a row; the add flow is a card carrying the dormant-provider
|
||||
* select. Every mutation writes through the wire, while a provider removal first requires
|
||||
* confirmation; the page re-renders from pushed invalidations or the
|
||||
* post-apply reload.
|
||||
* configured key renders as its open setup card instead of a row, but only in
|
||||
* the first-run posture — no provider on the page can serve requests yet — and
|
||||
* only until the user closes that card; the add flow is a card carrying the
|
||||
* dormant-provider select. Each card kind owns its own open state, so closing
|
||||
* one never discards a draft in another. Every mutation writes through the
|
||||
* wire, while a provider removal first requires confirmation; the page
|
||||
* re-renders from pushed invalidations or the post-apply reload.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
@@ -16,7 +18,7 @@ import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { Button, IconPlusOutline16, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { CustomProviderCard } from './CustomProviderCard.tsx'
|
||||
import { deriveKeyRef, messageOf, protocolChoices } from './store.ts'
|
||||
import { deriveKeyRef, messageOf, protocolChoices, providerUsable } from './store.ts'
|
||||
import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts'
|
||||
import { ProviderEditor, type ProviderEditorProps } from './ProviderEditor.tsx'
|
||||
import type { en } from './locales.ts'
|
||||
@@ -116,11 +118,15 @@ export async function removeProviderProfile(
|
||||
|
||||
/**
|
||||
* Whether a whole-section provider still needs its first key: an unconfigured
|
||||
* credential opens the setup card instead of showing a row.
|
||||
* credential opens the setup card instead of showing a row. This is the
|
||||
* first-run posture alone — a user who can already reach some provider gets an
|
||||
* ordinary row with the missing-key dot, since nothing here is blocking them.
|
||||
* @param row - the joined provider row.
|
||||
* @param anyUsable - whether any joined row can already serve requests.
|
||||
* @returns whether to render the setup card.
|
||||
*/
|
||||
export function needsSetup(row: ProviderRow): boolean {
|
||||
export function needsSetup(row: ProviderRow, anyUsable: boolean): boolean {
|
||||
if (anyUsable) return false
|
||||
if (row.entry.settingsPath.length > 0) return false
|
||||
return row.credential?.configured !== true
|
||||
}
|
||||
@@ -178,17 +184,32 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
const [deleteFailure, setDeleteFailure] = useState<string | undefined>(undefined)
|
||||
const [savedTarget, setSavedTarget] = useState<ProviderIdentity | undefined>(undefined)
|
||||
const [declaring, setDeclaring] = useState(false)
|
||||
const [dismissedSetup, setDismissedSetup] = useState<ReadonlySet<string>>(() => new Set())
|
||||
|
||||
const announceSaved = (target: ProviderIdentity): void => {
|
||||
// Announced only once the refreshed directory is in the snapshot the
|
||||
// notice reads its name from: an apply can rename the route, and the
|
||||
// target captured when the card opened still carries the old name.
|
||||
void controller.load().then(() => { setSavedTarget(target) })
|
||||
}
|
||||
|
||||
const closeEditor = (changed: boolean, target: ProviderIdentity): void => {
|
||||
setEditing(undefined)
|
||||
setAdding(false)
|
||||
setDeclaring(false)
|
||||
if (changed) {
|
||||
// Announced only once the refreshed directory is in the snapshot the
|
||||
// notice reads its name from: an apply can rename the route, and the
|
||||
// target captured when the card opened still carries the old name.
|
||||
void controller.load().then(() => { setSavedTarget(target) })
|
||||
}
|
||||
if (changed) announceSaved(target)
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a setup card, which owns none of the state above: the row-editor,
|
||||
* add, and declare cards each own one of those, so clearing them here would
|
||||
* discard a draft the user opened beside this card. Dismissal is this card's
|
||||
* own — the provider falls back to an ordinary row for the rest of the
|
||||
* session, and reopens through Edit.
|
||||
*/
|
||||
const closeSetup = (changed: boolean, target: ProviderIdentity): void => {
|
||||
setDismissedSetup(previous => new Set([...previous, target.provider]))
|
||||
if (changed) announceSaved(target)
|
||||
}
|
||||
|
||||
const closeDelete = (): void => {
|
||||
@@ -238,6 +259,9 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
? savedTarget
|
||||
: { provider: savedRow.entry.provider, displayName: savedRow.entry.displayName }
|
||||
|
||||
// One fact decides both first-run postures on this page and the onboarding
|
||||
// step: whether the user already has a provider to talk to.
|
||||
const anyUsable = state.rows.some(providerUsable)
|
||||
const configured = state.rows.filter(row => row.configured)
|
||||
const addable = state.rows.filter(row => !row.configured && row.entry.settingsNs !== '')
|
||||
const addTarget = adding ? editing : undefined
|
||||
@@ -265,9 +289,9 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
const namespace = state.namespaces.get(target.settingsNs)
|
||||
/* v8 ignore next -- the join marks a row configured only when its namespace resolved */
|
||||
if (namespace === undefined) return null
|
||||
if (needsSetup(row)) {
|
||||
if (needsSetup(row, anyUsable) && !dismissedSetup.has(row.entry.provider)) {
|
||||
// First-run posture: the provider exists but has no key — the
|
||||
// setup card IS its presence on the page.
|
||||
// setup card IS its presence on the page, until the user closes it.
|
||||
return (
|
||||
<li key={row.entry.provider} className={styles['setupCard']}>
|
||||
{renderProviderEditor({
|
||||
@@ -276,7 +300,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
api,
|
||||
t,
|
||||
readOnly: !state.writable,
|
||||
onClose: (changed) => { closeEditor(changed, target) },
|
||||
onClose: (changed) => { closeSetup(changed, target) },
|
||||
})}
|
||||
</li>
|
||||
)
|
||||
|
||||
@@ -189,32 +189,49 @@ export class ModelsSettingsStore {
|
||||
}
|
||||
}
|
||||
|
||||
/** DeepSeek onboarding readiness derived only from the shared Models join. */
|
||||
export type DeepSeekReadiness =
|
||||
/**
|
||||
* Whether a joined row can serve model requests as it stands: the route is
|
||||
* registered with the adapter registry, and whatever credential its resolved
|
||||
* profile names is stored. A profile naming no reference authenticates through
|
||||
* the provider's own path (the Bedrock chain, Vertex ADC, a gateway that needs
|
||||
* nothing), as does a live route with no settings address at all, so neither
|
||||
* owes this page a key.
|
||||
* @param row - one joined provider row.
|
||||
* @returns whether the user already has this provider to talk to.
|
||||
*/
|
||||
export function providerUsable(row: ProviderRow): boolean {
|
||||
if (!row.entry.active) return false
|
||||
if (row.apiKeyEnv === undefined) return true
|
||||
return row.credential?.configured === true
|
||||
}
|
||||
|
||||
/** First-run onboarding readiness derived only from the shared Models join. */
|
||||
export type OnboardingReadiness =
|
||||
| { kind: 'loading' }
|
||||
| { kind: 'adapter-absent' }
|
||||
| { kind: 'configured' }
|
||||
| { kind: 'provider-ready' }
|
||||
| { kind: 'credential-missing' }
|
||||
| {
|
||||
kind: 'unavailable'
|
||||
reason:
|
||||
| 'load-failed'
|
||||
| 'provider-inactive'
|
||||
| 'settings-unavailable'
|
||||
| 'credential-ref-unavailable'
|
||||
| 'credentials-unavailable'
|
||||
| 'settings-read-only'
|
||||
| 'credential-read-only'
|
||||
}
|
||||
|
||||
/**
|
||||
* Project official-DeepSeek readiness from the provider/settings/credential
|
||||
* join used by the Models page. A missing official configurable-provider
|
||||
* Project first-run readiness from the provider/settings/credential join used
|
||||
* by the Models page. The step exists to leave the user with a model to talk
|
||||
* to, so ANY usable provider ends it; only when none exists does the official
|
||||
* DeepSeek route — the one route the prompt can offer a key field for — decide
|
||||
* whether prompting can help. A missing official configurable-provider
|
||||
* declaration means the adapter is not repairable by navigating to Models.
|
||||
* @param state - current shared Models join snapshot.
|
||||
* @returns the onboarding state without reading a parallel fact source.
|
||||
*/
|
||||
export function deepSeekReadiness(state: ModelsSettingsState): DeepSeekReadiness {
|
||||
export function onboardingReadiness(state: ModelsSettingsState): OnboardingReadiness {
|
||||
if ((state.status === 'idle' || state.status === 'loading') && state.rows.length === 0) {
|
||||
return { kind: 'loading' }
|
||||
}
|
||||
@@ -224,6 +241,7 @@ export function deepSeekReadiness(state: ModelsSettingsState): DeepSeekReadiness
|
||||
reason: 'load-failed',
|
||||
}
|
||||
}
|
||||
if (state.rows.some(providerUsable)) return { kind: 'provider-ready' }
|
||||
const row = state.rows.find(candidate =>
|
||||
candidate.entry.provider === 'deepseek-official'
|
||||
&& candidate.entry.settingsNs === 'llm-deepseek'
|
||||
@@ -235,33 +253,14 @@ export function deepSeekReadiness(state: ModelsSettingsState): DeepSeekReadiness
|
||||
reason: 'provider-inactive',
|
||||
}
|
||||
}
|
||||
if (!row.configured) {
|
||||
return {
|
||||
kind: 'unavailable',
|
||||
reason: 'settings-unavailable',
|
||||
}
|
||||
}
|
||||
if (row.apiKeyEnv === undefined) {
|
||||
return {
|
||||
kind: 'unavailable',
|
||||
reason: 'credential-ref-unavailable',
|
||||
}
|
||||
}
|
||||
if (state.credentialError !== null) {
|
||||
// Past the usable gate an active route names a reference it has no stored
|
||||
// credential for, so the remaining questions are all about that credential.
|
||||
if (state.credentialError !== null || row.credential === undefined) {
|
||||
return {
|
||||
kind: 'unavailable',
|
||||
reason: 'credentials-unavailable',
|
||||
}
|
||||
}
|
||||
if (row.credential === undefined) {
|
||||
return {
|
||||
kind: 'unavailable',
|
||||
reason: 'credentials-unavailable',
|
||||
}
|
||||
}
|
||||
if (row.credential.configured) {
|
||||
return { kind: 'configured' }
|
||||
}
|
||||
if (!state.writable) {
|
||||
return {
|
||||
kind: 'unavailable',
|
||||
|
||||
Reference in New Issue
Block a user