Просмотр исходного кода

Merge pull request #2326 from deepseek-harness/fix/onboarding-reads-every-provider

fix(web): 任意可用提供方即结束首次引导,setup 卡片可关闭
Yichen Jiang 1 месяц назад
Родитель
Сommit
76805df26e

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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/bug-fix/2026-08-12-onboarding-reads-every-provider.md
+2026-08-12-onboarding-reads-every-provider.md: 1f247a6c93257c24052f55eb4297ec3c9c3df06d
+2026-08-12-onboarding-reads-every-provider.zh.md: fc6e43195a46eaea881f8b4bee3219b5e583b284

+ 38 - 0
.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.md

@@ -0,0 +1,38 @@
+# Agent Note: First-run readiness reads every provider, and the setup card closes
+
+Status: implemented
+
+English | [中文](2026-08-12-onboarding-reads-every-provider.zh.md)
+
+## Problem
+
+The first-run step and the Models page both asked one question — is `deepseek-official`'s credential stored? — of a join that describes every provider. Two defects followed from that single reading.
+
+A user who configured some other provider (a pi-ai gateway, a self-hosted route) and never wanted the official DeepSeek endpoint was taken over by the full-screen credential prompt on every blank session, with a working model already selected in the composer behind it. Nothing they could do short of storing a DeepSeek key would end it, because the step's readiness projection never looked at the row they had configured.
+
+On the Models page the same reading opened the DeepSeek setup card over them on every visit, and that card could not be closed: it was rendered from row data with no local state a Cancel could flip, so its Cancel button did nothing visible. Worse, it shared the row-editor/add/declare close handler, which unconditionally clears all three of those states — so cancelling the card that owned none of them discarded the add card's draft while staying open itself.
+
+## Decision
+
+One predicate answers what both surfaces actually need. `providerUsable(row)` is true when the route is registered with the adapter registry (`entry.active`) and whatever credential its resolved profile names is stored; a profile naming no reference authenticates through the provider's own path, as does a live route with no settings address, so neither owes this page a key.
+
+`onboardingReadiness` (renamed from `deepSeekReadiness`, which no longer describes what it reads) returns `provider-ready` as soon as any joined row is usable. Only a user with none of those reaches the official DeepSeek lookup, which is unchanged: it is the one route the prompt can offer a key field for. The gate subsumes two diagnostics the old projection carried — `settings-unavailable` and `credential-ref-unavailable` — because both described an active route the new gate now calls usable; the outcome for the user was already identical (the step completed without rendering).
+
+`needsSetup(row, anyUsable)` takes the same fact, so the setup card is the first-run posture alone. With another provider reachable, DeepSeek is an ordinary row carrying the missing-key dot, one Edit click from the same card.
+
+Each card kind now owns its own close handler. `closeSetup` records the provider in a component-local `dismissedSetup` set and touches nothing else; `closeEditor` keeps clearing the three states its cards own. Both route the post-save reload through one `announceSaved` helper. Dismissal is viewing state, like the open editor and the add card: a reload restores the first-run posture for a user still in it.
+
+## Alternatives considered
+
+- **Deriving readiness from the model catalog (`llm.models`) instead of the join.** It answers "can the user talk to something" most directly, but it costs a per-provider listing round trip on a surface that already holds the join, and a provider whose listing fails transiently would re-open onboarding.
+- **Requiring `row.configured` in `providerUsable`.** It reads as the stricter check, and would exclude exactly the routes a deployment mounts through `cordis.yml` without a configurable-provider declaration — live routes serving models that this page cannot configure. Registration, not configurability, is what makes a provider usable.
+- **Only adding the dismissal, leaving the card auto-opening.** It fixes the Cancel button and nothing else: a user with a working provider would still be handed the DeepSeek form on every visit to Models, which is the same misreading in a quieter form.
+- **Persisting the dismissal to settings.** A durable "do not ask about DeepSeek" flag is a second fact about first-run state that can disagree with the join. The credential itself already ends the posture permanently, and every other card on this page is session-local.
+
+## Consequences
+
+Onboarding now ends for reasons the DeepSeek route knows nothing about, so the step's name is the last thing tying it to that adapter; a future step that offers more than one route to configure would replace the prompt, not the readiness projection. The narrowed diagnostic union means an unresolvable `llm-deepseek` settings address is reported as `provider-ready` rather than as its own reason — the user-visible behavior is unchanged, and the Models page remains the diagnostic surface.
+
+## Testing
+
+Package tests pin `providerUsable` over the four join states and `onboardingReadiness` over both the new gate and every surviving diagnostic; the section tests cover the first-run posture, the plain-row posture, and the cancel that collapses the setup card while the add card keeps its draft. The `onboarding-usable-provider` web e2e lane replays the whole scenario through the real wire: cancel with both cards open, configure `minimax-cn` instead, reload, and find no takeover — with one aria golden of the dismissed state.

+ 38 - 0
.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.zh.md

@@ -0,0 +1,38 @@
+# Agent Note: First-run readiness reads every provider, and the setup card closes
+
+Status: implemented
+
+[English](2026-08-12-onboarding-reads-every-provider.md) | 中文
+
+## Problem
+
+首次使用引导步骤与 Models 页都只向一个描述全部提供方的联接快照提出了同一个问题——`deepseek-official` 的凭据存了吗?两个缺陷由这一次读取而来。
+
+配置了别的提供方(某个 pi-ai 网关、某条自建路由)、根本不打算用 DeepSeek 官方端点的用户,会在每一个空白会话上被全屏凭据提示接管,而其背后输入框里早已选好了一个可用模型。除了存入一把 DeepSeek 密钥,他们做什么都结束不了它——因为该步骤的就绪投影从不看他们已经配好的那一行。
+
+在 Models 页上,同一次读取每次进入都会把 DeepSeek 设置卡片展开在他们面前,而这张卡片关不掉:它由行数据渲染而来,没有任何本地状态可供「取消」翻转,因此那颗取消按钮不产生任何可见效果。更糟的是,它与行内编辑卡/新增卡/自定义声明卡共用同一个关闭回调,而该回调会无条件清空那三个状态——于是取消一张它们一个都不拥有的卡片,反而丢弃了新增卡里的草稿,自己却仍然开着。
+
+## Decision
+
+一个谓词回答两处界面真正需要的事实。`providerUsable(row)` 在路由已注册进适配器注册表(`entry.active`)、且其解析后 profile 所指名的凭据已存储时为真;不指名任何引用的 profile 走提供方自己的认证路径,没有 settings 地址的存活路由亦然,因此二者都不欠这个页面一把密钥。
+
+`onboardingReadiness`(原名 `deepSeekReadiness`,该名称已不再描述它读取的内容)只要联接中有任意一行可用,就返回 `provider-ready`。只有二者皆无的用户才会走到官方 DeepSeek 查找,那部分保持不变:它是这条提示唯一能为其提供密钥输入框的路由。这道门槛吸收了旧投影携带的两个诊断——`settings-unavailable` 与 `credential-ref-unavailable`——因为二者描述的都是新门槛现在判为可用的活跃路由;对用户而言结果本就一致(该步骤不渲染直接完成)。
+
+`needsSetup(row, anyUsable)` 接受同一个事实,因此设置卡片仅代表首次运行姿态。当另有可触达的提供方时,DeepSeek 就是一行带缺失密钥点的普通行,距离同一张卡片只有一次「编辑」点击。
+
+现在每一类卡片各自拥有自己的关闭回调。`closeSetup` 把该提供方记入组件本地的 `dismissedSetup` 集合,别的一概不碰;`closeEditor` 继续清空它那些卡片所拥有的三个状态。两者都经由同一个 `announceSaved` 助手完成保存后的重载。关闭状态属于查看态,与展开的编辑卡和新增卡一样:对仍处于首次运行姿态的用户,重载会恢复该姿态。
+
+## Alternatives considered
+
+- **从模型目录(`llm.models`)而非联接推导就绪状态。** 它最直接地回答「用户有没有能对话的东西」,但会在一个已经持有联接的界面上多花每提供方一次列举往返,而且某个提供方列举的瞬时失败会让引导重新弹出。
+- **在 `providerUsable` 中要求 `row.configured`。** 它读起来更严格,却会恰好排除部署通过 `cordis.yml` 挂载、没有可配置提供方声明的那些路由——它们是正在提供模型、只是这个页面配置不了的存活路由。使一个提供方可用的是注册,不是可配置性。
+- **只加关闭状态,保留卡片自动展开。** 那只修好取消按钮,别的什么都没修:已有可用提供方的用户每次进入 Models 仍会被塞一张 DeepSeek 表单,那是同一个误读的安静版本。
+- **把关闭状态持久化到 settings。** 一个「别再问 DeepSeek」的持久标志,是关于首次运行状态的第二个事实,可能与联接互相矛盾。凭据本身已经永久结束该姿态,而这个页面上其他每一张卡片都是会话内的。
+
+## Consequences
+
+引导现在会因为 DeepSeek 路由一无所知的理由而结束,因此该步骤的名字是最后一处把它和那个适配器绑在一起的东西;未来若有一个步骤能提供不止一条可配置路由,替换掉的会是提示本身,而非就绪投影。收窄后的诊断联合意味着无法解析的 `llm-deepseek` settings 地址会被报为 `provider-ready` 而非它自己的理由——用户可见行为不变,Models 页仍是诊断界面。
+
+## Testing
+
+包内测试针对四种联接状态钉住 `providerUsable`,并针对新门槛与每一个存留的诊断钉住 `onboardingReadiness`;分区测试覆盖首次运行姿态、普通行姿态,以及在新增卡保住草稿的同时折叠设置卡片的那次取消。`onboarding-usable-provider` web e2e 泳道通过真实协议重放整个场景:两张卡片都开着时取消、改配 `minimax-cn`、重载,然后不再出现接管——并附一份关闭后状态的 aria golden。

+ 128 - 0
apps/web/tests/onboarding-usable-provider.e2e.ts

@@ -0,0 +1,128 @@
+// Keyless browser e2e: a user who configures some OTHER provider is not asked
+// for the official DeepSeek key again, and the first-run setup card is a card
+// they can close. The shipped DeepSeek adapter stays mounted without a
+// credential throughout, so the only thing that ends onboarding here is the
+// pi-ai route the user configures through the real wire. Zero model calls:
+// configuration is pure settings/credentials/llm-domain traffic.
+import { readFile } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+import { join } from 'node:path'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import {
+  acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
+  launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/onboarding-usable-provider', import.meta.url))
+const DISMISSED_EXPECTED = join(SNAPSHOT_DIR, 'dismissed.expected.md')
+const MODE = webSnapshotMode()
+const CREDENTIAL_STEP = '添加一个 API Key 开始使用'
+
+describe.skipIf(MODE === 'record')('web e2e: another usable provider ends first-run onboarding', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType<typeof watchConsole>
+
+  beforeAll(async () => {
+    scaffold = await launchWebScaffold({ deepSeekMissingCredential: true })
+    browser = await chromium.launch()
+    // The scenario asserts the shipped Chinese copy, so the browser asks for it.
+    page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE })
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+  }, 120_000)
+
+  afterAll(async () => {
+    await browser?.close()
+    await scaffold?.close()
+  })
+
+  it('closes the setup card without discarding the add card beside it', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-setup-card-cancel'))
+    const credentialStep = page.getByRole('region', { name: CREDENTIAL_STEP })
+    await credentialStep.waitFor({ timeout: 15_000 })
+    await credentialStep.getByRole('button', { name: '前往配置' }).click()
+    await credentialStep.waitFor({ state: 'detached', timeout: 15_000 })
+
+    const settings = page.getByRole('dialog', { name: '设置' })
+    await settings.waitFor({ timeout: 10_000 })
+    // Nothing is reachable yet, so DeepSeek presents itself as its open card.
+    const setupKey = settings.getByRole('textbox', { name: 'API 密钥', exact: true })
+    await setupKey.waitFor({ timeout: 10_000 })
+
+    const add = settings.getByRole('button', { name: '添加提供方' })
+    await expect.poll(async () => add.isEnabled(), { timeout: 10_000 }).toBe(true)
+    await add.click()
+    const pick = settings.getByLabel('提供方')
+    await pick.waitFor({ timeout: 10_000 })
+    await pick.selectOption('minimax-cn')
+    await expect.poll(
+      async () => settings.getByRole('textbox', { name: 'API 密钥', exact: true }).count(),
+      { timeout: 10_000 },
+    ).toBe(2)
+
+    // Cancelling the setup card is the regression: it used to leave itself open
+    // and close the add card, discarding that draft.
+    await settings.getByRole('button', { name: '取消', exact: true }).first().click()
+    expect(await settings.getByLabel('提供方').count()).toBe(1)
+    await expect.poll(
+      async () => settings.getByRole('textbox', { name: 'API 密钥', exact: true }).count(),
+      { timeout: 10_000 },
+    ).toBe(1)
+    // DeepSeek is now an ordinary row: a missing-key dot and an Edit button.
+    await settings.getByRole('button', { name: '编辑 DeepSeek (deepseek-official)' }).waitFor({ timeout: 10_000 })
+    const dismissed = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(DISMISSED_EXPECTED, dismissed, MODE)
+
+    expect(tripwire.warnings).toEqual([])
+    expect(tripwire.pageErrors).toEqual([])
+  }, 60_000)
+
+  it('stops prompting for DeepSeek once the other provider can serve requests', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-other-provider'))
+    const settings = page.getByRole('dialog', { name: '设置' })
+    await settings.getByRole('textbox', { name: 'API 密钥', exact: true }).fill('sk-e2e-minimax')
+    await settings.getByRole('button', { name: '保存', exact: true }).click()
+    await settings.getByText('已保存 minimax-cn。', { exact: true }).waitFor({ timeout: 15_000 })
+
+    // Only minimax-cn is reachable; DeepSeek still holds no credential.
+    const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
+    expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
+    const credentials = await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8')
+    expect(credentials).toContain('MINIMAX_CN_API_KEY: sk-e2e-minimax')
+    expect(credentials).not.toContain('DEEPSEEK_API_KEY')
+
+    const warningsBefore = tripwire.warnings.length
+    await page.reload({ waitUntil: 'load' })
+    acknowledgeReloadConnectionLoss(tripwire, warningsBefore)
+    await page.waitForSelector('[class*="frame"]', { timeout: 15_000 })
+    // The regression: the step read only the official route's credential, so a
+    // fully configured user was taken over on every blank session.
+    await expect.poll(
+      async () => page.getByRole('region', { name: CREDENTIAL_STEP }).count(),
+      { timeout: 10_000 },
+    ).toBe(0)
+    expect(await page.locator('[class*="onboardingStage"]').count()).toBe(0)
+    expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(false)
+
+    // The Models page agrees: DeepSeek stays a row rather than reopening its
+    // setup card over a user who already has somewhere to send a request.
+    await page.getByRole('button', { name: '设置', exact: true }).click()
+    await settings.waitFor({ timeout: 10_000 })
+    await settings.getByRole('button', { name: '模型' }).click()
+    await settings.getByRole('button', { name: '编辑 DeepSeek (deepseek-official)' }).waitFor({ timeout: 10_000 })
+    expect(await settings.getByRole('textbox', { name: 'API 密钥', exact: true }).count()).toBe(0)
+
+    expect((await page.content()).includes('sk-e2e-minimax')).toBe(false)
+    expect(tripwire.pageErrors).toEqual([])
+  }, 60_000)
+
+  it('keeps the fixture inventory closed', async () => {
+    await assertFixtureInventory(SNAPSHOT_DIR, ['dismissed.expected.md'])
+  })
+})

+ 71 - 0
apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md

@@ -0,0 +1,71 @@
+- dialog "设置":
+  - navigation:
+    - text: 设置
+    - button "通用设置":
+      - img
+      - text: 通用设置
+    - button "模型":
+      - img
+      - text: 模型
+    - button "Agent 预设":
+      - img
+      - text: Agent 预设
+    - button "插件配置":
+      - img
+      - text: 插件配置
+  - button "打开配置文件"
+  - button "关闭":
+    - img
+    - text: 关闭
+  - heading "模型" [level=2]
+  - paragraph: 填入各提供方的 API 密钥即可使用其模型。
+  - list:
+    - listitem:
+      - text: DeepSeek
+      - img "API 密钥缺失"
+      - button "编辑 DeepSeek (deepseek-official)": 编辑
+  - text: 提供方
+  - combobox "提供方":
+    - option "amazon-bedrock"
+    - option "ant-ling"
+    - option "anthropic"
+    - option "azure-openai-responses"
+    - option "cerebras"
+    - option "cloudflare-ai-gateway"
+    - option "cloudflare-workers-ai"
+    - option "deepseek"
+    - option "fireworks"
+    - option "github-copilot"
+    - option "google"
+    - option "google-vertex"
+    - option "groq"
+    - option "huggingface"
+    - option "kimi-coding"
+    - option "minimax"
+    - option "minimax-cn" [selected]
+    - option "mistral"
+    - option "moonshotai"
+    - option "moonshotai-cn"
+    - option "nvidia"
+    - option "openai"
+    - option "openai-codex"
+    - option "opencode"
+    - option "opencode-go"
+    - option "openrouter"
+    - option "qwen-token-plan"
+    - option "qwen-token-plan-cn"
+    - option "together"
+    - option "vercel-ai-gateway"
+    - option "xai"
+    - option "xiaomi"
+    - option "xiaomi-token-plan-ams"
+    - option "xiaomi-token-plan-cn"
+    - option "xiaomi-token-plan-sgp"
+    - option "zai"
+    - option "zai-coding-cn"
+  - text: API 密钥
+  - textbox "API 密钥":
+    - /placeholder: 输入 API 密钥,或留空使用环境认证
+  - group: 自定义设置
+  - button "取消"
+  - button "保存"

+ 1 - 0
apps/web/tsconfig.json

@@ -42,6 +42,7 @@
     "tests/default-model.e2e.ts",
     "tests/declared-reasoning.e2e.ts",
     "tests/onboarding-deepseek-config.e2e.ts",
+    "tests/onboarding-usable-provider.e2e.ts",
     "tests/remote-welcome.e2e.ts",
     "tests/workspace-management.e2e.ts",
     "tests/replay-round-trip.e2e.ts",

+ 2 - 2
packages/client/ui-models/README.i18n.yaml

@@ -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 packages/client/ui-models/README.md
-README.md: f6604f822412e9eb4574696f5b99e73fb7bd98ff
-README.zh.md: 2500bbae0982571a9a88dd5c259749e3504728de
+README.md: a8d030b7676e87709fb36b87a6599decc43e0b4b
+README.zh.md: 63fb1b486acc2bca34792f485ffd89fb32749e43

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
packages/client/ui-models/README.md


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
packages/client/ui-models/README.zh.md


+ 10 - 8
packages/client/ui-models/src/client/DeepSeekOnboardingDialog.tsx

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

+ 41 - 17
packages/client/ui-models/src/client/ModelsSection.tsx

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

+ 29 - 30
packages/client/ui-models/src/client/store.ts

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

+ 130 - 71
packages/client/ui-models/tests/components.client.spec.tsx

@@ -23,6 +23,8 @@ afterEach(cleanup)
 const t: ModelsSectionInjected['t'] = key => en[key]
 const OPENAI_TARGET = { provider: 'openai', displayName: 'openai' }
 const openaiCopy = (template: string): string => providerCopy(template, OPENAI_TARGET)
+const DEEPSEEK_TARGET = { provider: 'deepseek-official', displayName: 'DeepSeek' }
+const deepSeekCopy = (template: string): string => providerCopy(template, DEEPSEEK_TARGET)
 
 /** Open one row's capacity disclosure (1-based, as the labels read). */
 function expandRow(position: number): void {
@@ -181,8 +183,8 @@ function scriptedFace(overrides: {
 
 type WireFace = ConstructorParameters<typeof ModelsSettingsStore>[0]
 
-async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {}) {
-  const { face, update, replace, mutate, set, unset } = scriptedFace(overrides)
+async function mountFace(scripted: ReturnType<typeof scriptedFace>) {
+  const { face, update, replace, mutate, set, unset } = scripted
   const controller = new ModelsSettingsStore(face as unknown as WireFace)
   await controller.load()
   const injected: ModelsSectionInjected = {
@@ -195,6 +197,34 @@ async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {})
   return { view, face, update, replace, mutate, set, unset, controller }
 }
 
+async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {}) {
+  return mountFace(scriptedFace(overrides))
+}
+
+/**
+ * Mount for a user who cannot reach any provider yet: no credential is stored
+ * anywhere, so the whole-section DeepSeek route owns the first-run setup card.
+ */
+async function mountFirstRun(overrides: Parameters<typeof scriptedFace>[0] = {}) {
+  const scripted = scriptedFace(overrides)
+  scripted.face.credentials.describe.mockImplementation((payload: { refs: string[] }) =>
+    Promise.resolve(ok({
+      credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: false, writable: true }])),
+    })))
+  return mountFace(scripted)
+}
+
+/**
+ * Mount and open the DeepSeek editor. The shared fixture already has a usable
+ * openai route, so DeepSeek is an ordinary row whose card opens through Edit
+ * rather than by itself.
+ */
+async function mountDeepSeekCard(overrides: Parameters<typeof scriptedFace>[0] = {}) {
+  const mounted = await mountSection(overrides)
+  fireEvent.click(screen.getByRole('button', { name: deepSeekCopy(en.editProvider) }))
+  return mounted
+}
+
 describe('ModelsSection', () => {
   it('renders nothing before the slot injects its dependencies', () => {
     const uninjected = {} as ModelsSectionProps
@@ -202,20 +232,32 @@ describe('ModelsSection', () => {
     expect(document.body.textContent).toBe('')
   })
 
-  it('renders the unkeyed whole-section provider as an open setup card beside the rows', async () => {
-    await mountSection()
-    // DeepSeek has no configured credential and no stored apiKey → setup card.
+  it('renders the unkeyed whole-section provider as an open setup card in the first-run posture', async () => {
+    await mountFirstRun()
+    // Nothing is reachable yet, and DeepSeek has no configured credential and
+    // no stored apiKey → setup card.
     expect(screen.getByText('DeepSeek')).toBeTruthy()
     expect(screen.getByLabelText(en.keyInput)).toBeTruthy()
     expect(screen.getByText('openai')).toBeTruthy()
     expect(screen.queryByText('Active')).toBeNull()
     expect(screen.queryByText('Inactive')).toBeNull()
+    expect(screen.getByText(en.add)).toBeTruthy()
+  })
+
+  it('leaves the unkeyed provider a plain row once another provider is usable', async () => {
+    await mountSection()
+    // openai's key is stored, so the user is not blocked and nothing on the
+    // page opens itself over them.
+    expect(screen.queryByLabelText(en.keyInput)).toBeNull()
     const configured = screen.getByRole('img', { name: en.credentialConfigured })
     expect(configured.getAttribute('title')).toBe(en.credentialConfigured)
     expect(configured.className).toContain('credentialDotConfigured')
     expect(configured.closest('li')?.textContent).toContain('openai')
-    expect(screen.queryByRole('img', { name: en.credentialMissing })).toBeNull()
-    expect(screen.getByText(en.add)).toBeTruthy()
+    const missing = screen.getByRole('img', { name: en.credentialMissing })
+    expect(missing.closest('li')?.textContent).toContain('DeepSeek')
+    // The card is still one click away.
+    fireEvent.click(screen.getByRole('button', { name: deepSeekCopy(en.editProvider) }))
+    expect(screen.getByLabelText(en.keyInput)).toBeTruthy()
   })
 
   it('marks only a confirmed missing reference and leaves native or unavailable state unmarked', async () => {
@@ -241,7 +283,7 @@ describe('ModelsSection', () => {
   })
 
   it('turns the setup card into a row once the credential reports configured', async () => {
-    const { face } = await mountSection()
+    const { face } = await mountFirstRun()
     face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({
       credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: true, writable: true }])),
     })))
@@ -259,7 +301,7 @@ describe('ModelsSection', () => {
     expect(screen.queryByLabelText(en.keyInput)).toBeNull()
   })
 
-  it('decides setup need from the joined credential state', () => {
+  it('decides setup need from the joined credential state and the first-run posture', () => {
     const entry = { provider: 'p', displayName: 'p', settingsNs: 'llm-deepseek', settingsPath: [], active: true }
     const row = (credential: ProviderRow['credential']): ProviderRow => ({
       entry,
@@ -268,10 +310,13 @@ describe('ModelsSection', () => {
       apiKeyEnv: 'X',
       credential,
     })
-    expect(needsSetup(row(undefined))).toBe(true)
-    expect(needsSetup(row({ configured: true, writable: true }))).toBe(false)
+    expect(needsSetup(row(undefined), false)).toBe(true)
+    expect(needsSetup(row({ configured: true, writable: true }), false)).toBe(false)
     const nested = { ...row(undefined), entry: { ...entry, settingsPath: ['providers', 'x'] } }
-    expect(needsSetup(nested)).toBe(false)
+    expect(needsSetup(nested, false)).toBe(false)
+    // A user who can already reach some provider is not in the first-run
+    // posture, so nothing on the page opens itself.
+    expect(needsSetup(row(undefined), true)).toBe(false)
   })
 
   it('derives conventional credential references from route ids', () => {
@@ -296,7 +341,7 @@ describe('ModelsSection', () => {
   })
 
   it('stores a typed key write-only from the setup card without touching settings', async () => {
-    const { set, update, face } = await mountSection()
+    const { set, update, face } = await mountFirstRun()
     const key = screen.getByLabelText<HTMLInputElement>(en.keyInput)
     fireEvent.change(key, { target: { value: '  sk-live  ' } })
     fireEvent.click(screen.getByText(en.apply))
@@ -311,7 +356,7 @@ describe('ModelsSection', () => {
   })
 
   it('applies customized deepseek fields as path ops', async () => {
-    const { mutate } = await mountSection({
+    const { mutate } = await mountDeepSeekCard({
       mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
     })
     fireEvent.click(screen.getByText(en.customized))
@@ -332,7 +377,7 @@ describe('ModelsSection', () => {
   })
 
   it('materializes inherited models and adds an arbitrary DeepSeek id', async () => {
-    const { mutate } = await mountSection({
+    const { mutate } = await mountDeepSeekCard({
       mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
     })
     fireEvent.click(screen.getByText(en.customized))
@@ -366,7 +411,7 @@ describe('ModelsSection', () => {
   })
 
   it('rejects duplicate DeepSeek model ids before writing', async () => {
-    const { mutate } = await mountSection()
+    const { mutate } = await mountDeepSeekCard()
     fireEvent.click(screen.getByText(en.customized))
     fireEvent.click(screen.getByText(en.addModel))
     const ids = screen.getAllByLabelText(new RegExp(en.modelId))
@@ -436,7 +481,7 @@ describe('ModelsSection', () => {
   })
 
   it('accepts a suffixed context window and stores the plain count', async () => {
-    const { mutate } = await mountSection({
+    const { mutate } = await mountDeepSeekCard({
       mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
     })
     fireEvent.click(screen.getByText(en.customized))
@@ -476,7 +521,7 @@ describe('ModelsSection', () => {
   })
 
   it('keeps unreadable context-window text on screen and refuses the write', async () => {
-    const { mutate } = await mountSection()
+    const { mutate } = await mountDeepSeekCard()
     fireEvent.click(screen.getByText(en.customized))
     expandRow(1)
     expandRow(2)
@@ -539,7 +584,7 @@ describe('ModelsSection', () => {
     // The regression: one active buffer meant editing a second row displaced
     // the first, which then fell back to rendering its stored NaN as `NaN` —
     // losing the text the user was told they could still correct.
-    await mountSection()
+    await mountDeepSeekCard()
     fireEvent.click(screen.getByText(en.customized))
     expandRow(1)
     expandRow(2)
@@ -553,7 +598,7 @@ describe('ModelsSection', () => {
   })
 
   it('re-keys the typed text around a removed row', async () => {
-    await mountSection()
+    await mountDeepSeekCard()
     fireEvent.click(screen.getByText(en.customized))
     const windows = (): HTMLInputElement[] => capacityInputs(en.contextWindow)
     const removeRow = (at: number): void => {
@@ -587,7 +632,7 @@ describe('ModelsSection', () => {
     // The regression: reset removed the override but left the buffer, so an
     // inherited row displayed text no settings layer stores — and because an
     // unreadable buffer never settles, it stayed there indefinitely.
-    const { mutate } = await mountSection({
+    const { mutate } = await mountDeepSeekCard({
       mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
     })
     fireEvent.click(screen.getByText(en.customized))
@@ -605,12 +650,12 @@ describe('ModelsSection', () => {
     // Reset put the draft back where it started, so Apply writes nothing at
     // all rather than persisting whatever the stale text had parsed to.
     fireEvent.click(screen.getByText(en.apply))
-    await waitFor(() => { expect(screen.getByText(en.apply)).toBeTruthy() })
+    await waitFor(() => { expect(screen.queryByText(en.apply)).toBeNull() })
     expect(mutate).not.toHaveBeenCalled()
   })
 
   it('edits an output cap per model and carries its text across a removal', async () => {
-    const { mutate } = await mountSection({
+    const { mutate } = await mountDeepSeekCard({
       mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
     })
     fireEvent.click(screen.getByText(en.customized))
@@ -644,7 +689,7 @@ describe('ModelsSection', () => {
   })
 
   it('settles a pasted id and refuses whitespace that would never match', async () => {
-    await mountSection()
+    await mountDeepSeekCard()
     fireEvent.click(screen.getByText(en.customized))
     const ids = screen.getAllByLabelText<HTMLInputElement>(new RegExp(en.modelId))
     fireEvent.change(ids[0] as HTMLInputElement, { target: { value: '  deepseek-v4-flash  ' } })
@@ -681,7 +726,7 @@ describe('ModelsSection', () => {
   })
 
   it('can empty and reset the model override, then clear optional fields without dropping hidden data', async () => {
-    const { mutate } = await mountSection({
+    const { mutate } = await mountDeepSeekCard({
       mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
     })
     fireEvent.click(screen.getByText(en.customized))
@@ -715,7 +760,7 @@ describe('ModelsSection', () => {
 
   it('clears an inherited override with an unset op, never a whole-section replace', async () => {
     // A whole-section replace would clobber sibling overrides to clear one field.
-    const { replace, update, mutate } = await mountSection()
+    const { replace, update, mutate } = await mountDeepSeekCard()
     fireEvent.click(screen.getByText(en.customized))
     const url = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
     expect(url.value).toBe('https://base')
@@ -762,7 +807,7 @@ describe('ModelsSection', () => {
   })
 
   it('rejects an invalid draft before writing', async () => {
-    const { update } = await mountSection()
+    const { update } = await mountDeepSeekCard()
     fireEvent.click(screen.getByText(en.customized))
     fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'not-a-url' } })
     fireEvent.click(screen.getByText(en.apply))
@@ -772,19 +817,17 @@ describe('ModelsSection', () => {
 
   it('edits a pi-ai profile with the curated fields only', async () => {
     const { mutate } = await mountSection()
-    fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
+    fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) }))
     // The configured credential shows as the stored placeholder.
-    const keys = await screen.findAllByLabelText<HTMLInputElement>(en.keyInput)
-    const editorKey = keys[keys.length - 1] as HTMLInputElement
+    const editorKey = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
     await waitFor(() => { expect(editorKey.placeholder).toBe(en.keyStored) })
     // pi-ai carries Base URL too: the stored override shows as the value and
     // the effective profile endpoint as its placeholder source.
-    fireEvent.click(screen.getAllByText(en.customized)[1] as HTMLElement)
-    const urls = screen.getAllByLabelText<HTMLInputElement>(en.baseUrl)
-    expect(urls).toHaveLength(2)
-    expect((urls[1] as HTMLInputElement).value).toBe('https://proxy')
-    fireEvent.change(urls[1] as HTMLInputElement, { target: { value: 'https://proxy/v2' } })
-    fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
+    fireEvent.click(screen.getByText(en.customized))
+    const url = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
+    expect(url.value).toBe('https://proxy')
+    fireEvent.change(url, { target: { value: 'https://proxy/v2' } })
+    fireEvent.click(screen.getByText(en.apply))
     await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
     // Only the edited field travels: apiKeyEnv and headers were already stored
     // with these values, so no op restates them.
@@ -803,14 +846,12 @@ describe('ModelsSection', () => {
     expect(pick.value).toBe('anthropic')
     // A dormant profile has no endpoint anywhere: the pi-ai placeholder
     // falls back to the provider-default wording.
-    fireEvent.click(screen.getAllByText(en.customized)[1] as HTMLElement)
-    const urls = screen.getAllByLabelText<HTMLInputElement>(en.baseUrl)
-    expect((urls[1] as HTMLInputElement).placeholder).toBe(en.baseUrlDefault)
-    const keys = screen.getAllByLabelText<HTMLInputElement>(en.keyInput)
-    const addKey = keys[keys.length - 1] as HTMLInputElement
+    fireEvent.click(screen.getByText(en.customized))
+    expect(screen.getByLabelText<HTMLInputElement>(en.baseUrl).placeholder).toBe(en.baseUrlDefault)
+    const addKey = screen.getByLabelText<HTMLInputElement>(en.keyInput)
     expect(addKey.placeholder).toBe(en.keyPlaceholderNative)
     fireEvent.change(addKey, { target: { value: 'sk-ant' } })
-    fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
+    fireEvent.click(screen.getByText(en.apply))
     await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
     expect(mutate.mock.calls[0]?.[0]).toEqual({
       ns: 'llm-pi-ai',
@@ -824,7 +865,7 @@ describe('ModelsSection', () => {
     const { mutate, set } = await mountSection()
     fireEvent.click(screen.getByText(en.add))
     await screen.findByLabelText(en.provider)
-    fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
+    fireEvent.click(screen.getByText(en.apply))
     await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
     expect(mutate.mock.calls[0]?.[0]).toEqual({
       ns: 'llm-pi-ai',
@@ -855,9 +896,8 @@ describe('ModelsSection', () => {
     const { face, controller } = await mountSection({ mutate, set })
     fireEvent.click(screen.getByText(en.add))
     await screen.findByLabelText(en.provider)
-    const keys = screen.getAllByLabelText<HTMLInputElement>(en.keyInput)
-    fireEvent.change(keys[keys.length - 1] as HTMLInputElement, { target: { value: 'sk-ant' } })
-    fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
+    fireEvent.change(screen.getByLabelText<HTMLInputElement>(en.keyInput), { target: { value: 'sk-ant' } })
+    fireEvent.click(screen.getByText(en.apply))
     await screen.findByText('credential store unavailable')
     expect(mutate).toHaveBeenCalledOnce()
     face.settings.describe.mockResolvedValue(ok({
@@ -867,7 +907,7 @@ describe('ModelsSection', () => {
     }))
     await act(async () => { await controller.load() })
     expect(controller.store.getSnapshot().namespaces.get('llm-pi-ai')?.revision).toBe(1)
-    fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
+    fireEvent.click(screen.getByText(en.apply))
     await waitFor(() => { expect(set).toHaveBeenCalledTimes(2) })
     expect(mutate).toHaveBeenCalledOnce()
     expect(set).toHaveBeenLastCalledWith({ ref: 'ANTHROPIC_API_KEY', value: 'sk-ant' })
@@ -883,10 +923,9 @@ describe('ModelsSection', () => {
     await waitFor(() => {
       expect(screen.getAllByText(content => content.includes(en.advancedHint)).length).toBeGreaterThan(0)
     })
-    // The hint-only card cannot apply anything.
-    const applies = screen.getAllByText<HTMLButtonElement>(en.apply)
-    expect((applies[applies.length - 1] as HTMLButtonElement).disabled).toBe(true)
-    expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
+    // The hint-only card cannot apply anything, and offers no key field.
+    expect(screen.getByText<HTMLButtonElement>(en.apply).disabled).toBe(true)
+    expect(screen.queryAllByLabelText(en.keyInput)).toHaveLength(0)
   })
 
   it('surfaces a rejected settings write and never stores the key after it', async () => {
@@ -895,9 +934,8 @@ describe('ModelsSection', () => {
     })
     fireEvent.click(screen.getByText(en.add))
     await screen.findByLabelText(en.provider)
-    const keys = screen.getAllByLabelText<HTMLInputElement>(en.keyInput)
-    fireEvent.change(keys[keys.length - 1] as HTMLInputElement, { target: { value: 'sk-x' } })
-    fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
+    fireEvent.change(screen.getByLabelText<HTMLInputElement>(en.keyInput), { target: { value: 'sk-x' } })
+    fireEvent.click(screen.getByText(en.apply))
     await screen.findByText(/unknown pi-ai provider/)
     expect(set).not.toHaveBeenCalled()
   })
@@ -930,7 +968,7 @@ describe('ModelsSection', () => {
   it('tells the user to reopen when another writer moved the namespace first', async () => {
     // The stale-draft overwrite: two tabs open the same card, the other saves,
     // and this one must be refused rather than replay its opening snapshot.
-    const { set } = await mountSection({
+    const { set } = await mountDeepSeekCard({
       mutate: vi.fn(() => Promise.resolve(fail('changed since it was read', 'settings-conflict'))),
     })
     fireEvent.click(screen.getByText(en.customized))
@@ -944,7 +982,7 @@ describe('ModelsSection', () => {
     // A transport failure (disconnect, or the 403 a non-loopback browser now
     // gets on the whole configuration plane) rejects rather than returning a
     // failed envelope: without a catch the card would stay busy forever.
-    await mountSection({ mutate: vi.fn(() => Promise.reject(new Error('connection lost'))) })
+    await mountDeepSeekCard({ mutate: vi.fn(() => Promise.reject(new Error('connection lost'))) })
     fireEvent.click(screen.getByText(en.customized))
     fireEvent.change(screen.getByLabelText<HTMLInputElement>(en.baseUrl), { target: { value: 'https://next' } })
     fireEvent.click(screen.getByText(en.apply))
@@ -954,7 +992,7 @@ describe('ModelsSection', () => {
   })
 
   it('surfaces a shadowed credential write on the card', async () => {
-    await mountSection({
+    await mountFirstRun({
       set: vi.fn(() => Promise.resolve(fail('credentials: DEEPSEEK_API_KEY is shadowed by the read-only environment', 'credential-rejected'))),
     })
     const key = screen.getByLabelText<HTMLInputElement>(en.keyInput)
@@ -971,9 +1009,8 @@ describe('ModelsSection', () => {
         configured: ref === 'OPENAI_API_KEY', source: 'env', writable: false,
       }])),
     })))
-    fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
-    const keys = await screen.findAllByLabelText<HTMLInputElement>(en.keyInput)
-    const editorKey = keys[keys.length - 1] as HTMLInputElement
+    fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) }))
+    const editorKey = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
     await waitFor(() => { expect(editorKey.placeholder).toBe(en.keyEnvLocked) })
     expect(editorKey.disabled).toBe(true)
   })
@@ -981,12 +1018,11 @@ describe('ModelsSection', () => {
   it('keeps a failed credential describe silent and the input usable', async () => {
     const { face, set } = await mountSection()
     face.credentials.describe.mockImplementation(() => Promise.resolve(fail('down', 'internal')) as never)
-    fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
-    const keys = await screen.findAllByLabelText<HTMLInputElement>(en.keyInput)
-    const editorKey = keys[keys.length - 1] as HTMLInputElement
+    fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) }))
+    const editorKey = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
     expect(editorKey.placeholder).toBe(en.keyPlaceholderNative)
     fireEvent.change(editorKey, { target: { value: 'sk-live' } })
-    fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
+    fireEvent.click(screen.getByText(en.apply))
     await waitFor(() => { expect(set).toHaveBeenCalledTimes(1) })
   })
 
@@ -1085,15 +1121,15 @@ describe('ModelsSection', () => {
 
   it('toggles the row editor closed on a second edit click and on cancel', async () => {
     const { update } = await mountSection()
-    const edit = screen.getAllByText(en.edit)[0] as HTMLElement
+    const edit = screen.getByRole('button', { name: openaiCopy(en.editProvider) })
     fireEvent.click(edit)
-    await waitFor(() => { expect(screen.getAllByLabelText(en.keyInput).length).toBe(2) })
+    await waitFor(() => { expect(screen.queryAllByLabelText(en.keyInput).length).toBe(1) })
     fireEvent.click(edit)
-    expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
+    expect(screen.queryAllByLabelText(en.keyInput)).toHaveLength(0)
     fireEvent.click(edit)
-    await waitFor(() => { expect(screen.getAllByLabelText(en.keyInput).length).toBe(2) })
-    fireEvent.click(screen.getAllByText(en.cancel)[1] as HTMLElement)
-    expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
+    await waitFor(() => { expect(screen.queryAllByLabelText(en.keyInput).length).toBe(1) })
+    fireEvent.click(screen.getByText(en.cancel))
+    expect(screen.queryAllByLabelText(en.keyInput)).toHaveLength(0)
     expect(update).not.toHaveBeenCalled()
   })
 
@@ -1101,11 +1137,34 @@ describe('ModelsSection', () => {
     await mountSection()
     fireEvent.click(screen.getByText(en.add))
     await screen.findByLabelText(en.provider)
-    fireEvent.click(screen.getAllByText(en.cancel)[1] as HTMLElement)
+    fireEvent.click(screen.getByText(en.cancel))
     await screen.findByText(en.add)
     expect(screen.queryByLabelText(en.provider)).toBeNull()
   })
 
+  it('collapses the setup card on cancel without disturbing another open card', async () => {
+    // The regression: the setup card shared the row/add/declare close handler,
+    // so cancelling it discarded the add card's draft while staying open itself.
+    await mountFirstRun()
+    expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
+    fireEvent.click(screen.getByText(en.add))
+    await screen.findByLabelText(en.provider)
+    expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(2)
+
+    // The setup card is the first one on the page, above the add block.
+    fireEvent.click(screen.getAllByText(en.cancel)[0] as HTMLElement)
+    // The add card kept its draft…
+    expect(screen.getByLabelText(en.provider)).toBeTruthy()
+    // …and DeepSeek collapsed to an ordinary row carrying the missing-key dot.
+    expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
+    expect(screen.getAllByRole('img', { name: en.credentialMissing })
+      .some(dot => dot.closest('li')?.textContent?.includes('DeepSeek') === true)).toBe(true)
+    // Its card reopens through Edit, which closes the add card as any row does.
+    fireEvent.click(screen.getByRole('button', { name: deepSeekCopy(en.editProvider) }))
+    expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
+    expect(screen.queryByLabelText(en.provider)).toBeNull()
+  })
+
   it('loads on first render of an idle controller', async () => {
     const { face } = scriptedFace()
     const controller = new ModelsSettingsStore(face as unknown as WireFace)

+ 58 - 25
packages/client/ui-models/tests/readiness.client.spec.ts

@@ -1,8 +1,8 @@
-/** Pure official-DeepSeek readiness projection over the shared Models join. */
+/** Pure first-run readiness projection over the shared Models join. */
 import { describe, expect, it } from 'vitest'
 import type { CredentialView } from '@deepseek-ai/dsh-api-remotes/client'
 import type { ModelsSettingsState, ProviderRow } from '../src/client/store.ts'
-import { deepSeekReadiness } from '../src/client/store.ts'
+import { onboardingReadiness, providerUsable } from '../src/client/store.ts'
 
 const missingCredential: CredentialView = { configured: false, writable: true }
 
@@ -23,6 +23,24 @@ function row(overrides: Partial<ProviderRow> = {}): ProviderRow {
   }
 }
 
+/** A second provider the user configured themselves. */
+function otherRow(overrides: Partial<ProviderRow> = {}): ProviderRow {
+  return {
+    entry: {
+      provider: 'hfai',
+      displayName: 'HFAI',
+      settingsNs: 'llm-pi-ai',
+      settingsPath: ['providers', 'hfai'],
+      active: true,
+    },
+    configured: true,
+    removable: true,
+    apiKeyEnv: 'HFAI_API_KEY',
+    credential: { configured: true, source: 'file', writable: true },
+    ...overrides,
+  }
+}
+
 function state(overrides: Partial<ModelsSettingsState> = {}): ModelsSettingsState {
   return {
     status: 'ready',
@@ -35,12 +53,25 @@ function state(overrides: Partial<ModelsSettingsState> = {}): ModelsSettingsStat
   }
 }
 
-describe('deepSeekReadiness', () => {
+describe('providerUsable', () => {
+  it('requires a registered route and a stored key for every named reference', () => {
+    expect(providerUsable(otherRow())).toBe(true)
+    expect(providerUsable(otherRow({ entry: { ...otherRow().entry, active: false } }))).toBe(false)
+    expect(providerUsable(otherRow({ credential: missingCredential }))).toBe(false)
+    expect(providerUsable(otherRow({ credential: undefined }))).toBe(false)
+  })
+
+  it('treats a reference-free registered route as provider-native authentication', () => {
+    expect(providerUsable(otherRow({ apiKeyEnv: undefined, credential: undefined }))).toBe(true)
+  })
+})
+
+describe('onboardingReadiness', () => {
   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' })
-    expect(deepSeekReadiness(state({
+    expect(onboardingReadiness(state({ status: 'idle', rows: [] }))).toEqual({ kind: 'loading' })
+    expect(onboardingReadiness(state({ status: 'loading', rows: [] }))).toEqual({ kind: 'loading' })
+    expect(onboardingReadiness(state({ rows: [] }))).toEqual({ kind: 'adapter-absent' })
+    expect(onboardingReadiness(state({
       rows: [row({
         entry: {
           ...row().entry,
@@ -51,45 +82,47 @@ describe('deepSeekReadiness', () => {
   })
 
   it('reports a missing writable effective credential', () => {
-    expect(deepSeekReadiness(state())).toEqual({ kind: 'credential-missing' })
+    expect(onboardingReadiness(state())).toEqual({ kind: 'credential-missing' })
+  })
+
+  it('ends onboarding once any other registered provider can serve requests', () => {
+    expect(onboardingReadiness(state({ rows: [row(), otherRow()] }))).toEqual({ kind: 'provider-ready' })
+    // A provider the user cannot reach yet leaves the prompt in place.
+    expect(onboardingReadiness(state({
+      rows: [row(), otherRow({ credential: missingCredential })],
+    }))).toEqual({ kind: 'credential-missing' })
   })
 
   it('accepts file and process-environment credentials without prompting', () => {
-    expect(deepSeekReadiness(state({
+    expect(onboardingReadiness(state({
       rows: [row({ credential: { configured: true, source: 'file', writable: true } })],
-    }))).toEqual({ kind: 'configured' })
-    expect(deepSeekReadiness(state({
+    }))).toEqual({ kind: 'provider-ready' })
+    expect(onboardingReadiness(state({
       rows: [row({ credential: { configured: true, source: 'env', writable: false } })],
-    }))).toEqual({ kind: 'configured' })
+    }))).toEqual({ kind: 'provider-ready' })
   })
 
-  it('turns missing capabilities and inconsistent descriptors into diagnostics', () => {
-    expect(deepSeekReadiness(state({ status: 'error', error: 'settings down' }))).toEqual({
+  it('turns missing capabilities into diagnostics that never block the product', () => {
+    expect(onboardingReadiness(state({ status: 'error', error: 'settings down' }))).toEqual({
       kind: 'unavailable',
       reason: 'load-failed',
     })
-    expect(deepSeekReadiness(state({
+    expect(onboardingReadiness(state({
       rows: [row({ entry: { ...row().entry, active: false } })],
     }))).toEqual({ kind: 'unavailable', reason: 'provider-inactive' })
-    expect(deepSeekReadiness(state({
-      rows: [row({ configured: false })],
-    }))).toEqual({ kind: 'unavailable', reason: 'settings-unavailable' })
-    expect(deepSeekReadiness(state({
-      rows: [row({ apiKeyEnv: undefined })],
-    }))).toEqual({ kind: 'unavailable', reason: 'credential-ref-unavailable' })
-    expect(deepSeekReadiness(state({
+    expect(onboardingReadiness(state({
       credentialError: 'credentials service is absent',
     }))).toEqual({
       kind: 'unavailable',
       reason: 'credentials-unavailable',
     })
-    expect(deepSeekReadiness(state({
+    expect(onboardingReadiness(state({
       rows: [row({ credential: undefined })],
     }))).toEqual({ kind: 'unavailable', reason: 'credentials-unavailable' })
-    expect(deepSeekReadiness(state({
+    expect(onboardingReadiness(state({
       rows: [row({ credential: { configured: false, writable: false } })],
     }))).toEqual({ kind: 'unavailable', reason: 'credential-read-only' })
-    expect(deepSeekReadiness(state({ writable: false }))).toEqual({
+    expect(onboardingReadiness(state({ writable: false }))).toEqual({
       kind: 'unavailable',
       reason: 'settings-read-only',
     })

+ 1 - 0
tsconfig.host.json

@@ -29,6 +29,7 @@
     "apps/web/tests/settings-chrome.e2e.ts",
     "apps/web/tests/models-settings.e2e.ts",
     "apps/web/tests/onboarding-deepseek-config.e2e.ts",
+    "apps/web/tests/onboarding-usable-provider.e2e.ts",
     "apps/web/tests/remote-welcome.e2e.ts",
     "apps/web/tests/workspace-management.e2e.ts",
     "apps/web/tests/replay-round-trip.e2e.ts",

Некоторые файлы не были показаны из-за большого количества измененных файлов