fix(web): improve models settings safety and contrast
This commit is contained in:
@@ -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: adfbc084e1b0e227d50032cb6c924401b81c6a79
|
||||
README.zh.md: 4ee7d4efa729fdccee392ab8e55078b5a4a239ef
|
||||
README.md: 9dd09faeb515bb8e8336c52418ab5cab5ede1b34
|
||||
README.zh.md: 753ee24f6dda647afef42b78cfc97f36dde5d739
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
Models settings plugin: the provider configuration page and official-DeepSeek first-run routing overlay. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time.
|
||||
|
||||
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), plus `reasoningEffort` (deepseek) or `reasoning` (pi-ai); every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base).
|
||||
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), plus `reasoningEffort` (deepseek) or `reasoning` (pi-ai); every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and a localized confirmation dialog must complete before the page submits that destructive unset.
|
||||
|
||||
The first-run overlay projects `deepseek-official` readiness from that same joined snapshot. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference suppresses the prompt, including a read-only launch-environment credential. Only a mounted adapter with a missing writable reference shows the action that opens Settings on the Models section, whose existing setup card exclusively owns key input and `credentials.set`; the overlay never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability is skipped so onboarding cannot block the rest of the product; the Models page remains the diagnostic surface.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
模型设置插件:提供方配置页和 DeepSeek 官方首次使用跳转浮层。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片。
|
||||
|
||||
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另加 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai);其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base)。
|
||||
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另加 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai);其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),而且必须先在本地化对话框中确认,页面才会提交这次破坏性的 unset。
|
||||
|
||||
首次使用浮层从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此不会把同一提供方 ID 下没有相应声明的存活路由视为可通过配置修复。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,浮层就不再显示,其中包括来自启动环境且只读的凭据。只有适配器已挂载、引用可写但尚未配置时,浮层才显示一个操作按钮,用于打开「设置」的 Models 分区;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,浮层绝不持有 secret。适配器缺失、路由未激活、联接失败、部署只读、设置能力不可用或凭据能力不可用时均跳过,以免首次使用引导阻塞产品的其他部分;Models 页仍是诊断界面。
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-width: 720px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.title {
|
||||
@@ -14,13 +15,13 @@
|
||||
.intro {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary, #888);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-warning, #a15c00);
|
||||
color: var(--dsw-alias-state-warn-label);
|
||||
}
|
||||
|
||||
.rows {
|
||||
@@ -33,13 +34,13 @@
|
||||
}
|
||||
|
||||
.rowCard {
|
||||
border: 1px solid var(--border, #e2e2e2);
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
background: var(--surface, #fff);
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
}
|
||||
|
||||
.rowHead {
|
||||
@@ -63,7 +64,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--text-success, #0a7d33);
|
||||
color: var(--dsw-alias-state-success-primary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -76,12 +77,12 @@
|
||||
}
|
||||
|
||||
.badgeMuted {
|
||||
color: var(--text-tertiary, #999);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.badgeWarn {
|
||||
color: var(--text-warning, #a15c00);
|
||||
color: var(--dsw-alias-state-warn-label);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -94,17 +95,17 @@
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 8px 18px;
|
||||
background: var(--accent-strong, #111);
|
||||
color: var(--text-inverse, #fff);
|
||||
background: var(--dsw-alias-button-primary-fill);
|
||||
color: var(--dsw-alias-label-primary-foreground);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.secondaryButton {
|
||||
border: 1px solid var(--border, #d9d9d9);
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 999px;
|
||||
padding: 6px 14px;
|
||||
background: var(--surface, #fff);
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
@@ -113,7 +114,7 @@
|
||||
.dangerButton {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-danger, #c0392b);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -126,9 +127,9 @@
|
||||
}
|
||||
|
||||
.editor {
|
||||
border: 1px solid var(--border, #e6e6e6);
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 12px;
|
||||
background: var(--surface-secondary, #f7f7f8);
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -148,7 +149,7 @@
|
||||
|
||||
.editorRoute {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary, #999);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.field {
|
||||
@@ -163,14 +164,14 @@
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #555);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.linkButton {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: var(--text-tertiary, #888);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
text-decoration: underline;
|
||||
@@ -185,7 +186,7 @@
|
||||
.advancedHint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary, #999);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.editorActions {
|
||||
@@ -202,12 +203,12 @@
|
||||
|
||||
.addButton {
|
||||
align-self: flex-start;
|
||||
border: 1px solid var(--border, #d9d9d9);
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 999px;
|
||||
padding: 8px 16px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
background: var(--surface, #fff);
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -219,9 +220,9 @@
|
||||
|
||||
.addCard,
|
||||
.setupCard {
|
||||
border: 1px solid var(--border, #e6e6e6);
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 12px;
|
||||
background: var(--surface-secondary, #f7f7f8);
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -237,7 +238,7 @@
|
||||
}
|
||||
|
||||
.customized {
|
||||
border-top: 1px solid var(--border, #ececec);
|
||||
border-top: 1px solid var(--dsw-alias-border-l2);
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
@@ -245,7 +246,7 @@
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #555);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
list-style: revert;
|
||||
}
|
||||
|
||||
@@ -259,25 +260,38 @@
|
||||
.input {
|
||||
box-sizing: border-box;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--border, #d9d9d9);
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 10px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
background: var(--surface, #fff);
|
||||
color: inherit;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-strong, #111);
|
||||
border-color: var(--dsw-alias-brand-primary);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--text-tertiary, #aaa);
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-danger, #c0392b);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.deleteDialog {
|
||||
width: min(480px, 100%);
|
||||
}
|
||||
|
||||
.deleteConfirm:not(:disabled) {
|
||||
border-color: var(--dsw-alias-state-error-primary);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.deleteConfirm:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover-danger);
|
||||
}
|
||||
|
||||
@@ -4,13 +4,15 @@
|
||||
* card at a time. 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; the page re-renders from the pushed
|
||||
* invalidations or the post-apply reload.
|
||||
* 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'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { messageOf } from './store.ts'
|
||||
import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts'
|
||||
@@ -114,6 +116,8 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
const state = injected.useSnapshot(snapshot => snapshot)
|
||||
const [editing, setEditing] = useState<EditorTarget | undefined>(undefined)
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState<EditorTarget | undefined>(undefined)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
const closeEditor = (changed: boolean): void => {
|
||||
setEditing(undefined)
|
||||
@@ -121,6 +125,26 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
if (changed) void controller.load()
|
||||
}
|
||||
|
||||
const closeDelete = (): void => {
|
||||
if (deleting) return
|
||||
setDeleteTarget(undefined)
|
||||
}
|
||||
|
||||
const confirmDelete = (): void => {
|
||||
/* v8 ignore next -- the action only renders with a target and is disabled while a deletion is pending */
|
||||
if (deleteTarget === undefined || deleting) return
|
||||
setDeleting(true)
|
||||
void removeProviderProfile(api, controller, deleteTarget)
|
||||
.then((failure) => {
|
||||
if (failure !== undefined) {
|
||||
controller.fail(failure)
|
||||
return
|
||||
}
|
||||
setDeleteTarget(undefined)
|
||||
})
|
||||
.finally(() => { setDeleting(false) })
|
||||
}
|
||||
|
||||
if (state.status === 'idle') void controller.load()
|
||||
if (state.status === 'error') {
|
||||
/* v8 ignore next -- an error status always carries text; the fallback satisfies the nullable type */
|
||||
@@ -193,11 +217,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
type="button"
|
||||
className={styles['dangerButton']}
|
||||
disabled={!state.writable}
|
||||
onClick={() => {
|
||||
void removeProviderProfile(api, controller, target).then((failure) => {
|
||||
if (failure !== undefined) controller.fail(failure)
|
||||
})
|
||||
}}
|
||||
onClick={() => { setDeleteTarget(target) }}
|
||||
>
|
||||
{t('remove')}
|
||||
</button>
|
||||
@@ -276,6 +296,29 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Modal
|
||||
open={deleteTarget !== undefined}
|
||||
onClose={closeDelete}
|
||||
title={t('deleteTitle')}
|
||||
closeLabel={t('close')}
|
||||
description={t('deleteDescription')}
|
||||
className={styles['deleteDialog'] as string}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" autoFocus disabled={deleting} onClick={closeDelete}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={styles['deleteConfirm']}
|
||||
disabled={deleting}
|
||||
onClick={confirmDelete}
|
||||
>
|
||||
{deleting ? t('deleting') : t('deleteConfirm')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,8 +9,13 @@ export const en = {
|
||||
dormant: 'Inactive',
|
||||
edit: 'Edit',
|
||||
remove: 'Delete',
|
||||
deleteTitle: 'Delete model provider?',
|
||||
deleteDescription: 'Deleting this model provider removes its configuration. You will not be able to use its models until you add the provider again.',
|
||||
deleteConfirm: 'Delete provider',
|
||||
deleting: 'Deleting provider…',
|
||||
add: 'Add provider',
|
||||
provider: 'Provider',
|
||||
close: 'Close',
|
||||
cancel: 'Cancel',
|
||||
apply: 'Apply',
|
||||
applying: 'Applying…',
|
||||
@@ -46,8 +51,13 @@ export const zh: typeof en = {
|
||||
dormant: '未启用',
|
||||
edit: '编辑',
|
||||
remove: '删除',
|
||||
deleteTitle: '删除模型提供方?',
|
||||
deleteDescription: '删除此模型提供方会移除其配置。在重新添加前,你将无法继续使用其模型。',
|
||||
deleteConfirm: '删除提供方',
|
||||
deleting: '正在删除提供方…',
|
||||
add: '添加提供方',
|
||||
provider: '提供方',
|
||||
close: '关闭',
|
||||
cancel: '取消',
|
||||
apply: '保存',
|
||||
applying: '保存中…',
|
||||
|
||||
@@ -48,6 +48,7 @@ describe('ui-models apply', () => {
|
||||
expect(resolveSlotLabel(entry.options.label)).toBe('模型')
|
||||
const injected = (entry.inject as unknown as () => import('../src/client/ModelsSection.tsx').ModelsSectionInjected)()
|
||||
expect(injected.t('nav')).toBe('模型')
|
||||
expect(injected.t('deleteTitle')).toBe('删除模型提供方?')
|
||||
expect(typeof injected.controller.load).toBe('function')
|
||||
expect(typeof injected.useSnapshot).toBe('function')
|
||||
expect(injected.api).toBeDefined()
|
||||
@@ -73,8 +74,11 @@ describe('ui-models apply', () => {
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
b.locale.setLocale('en')
|
||||
expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('Models')
|
||||
const injected = b.slots.entries('settings.section')[0]!.inject as unknown as () => import('../src/client/ModelsSection.tsx').ModelsSectionInjected
|
||||
expect(injected().t('deleteTitle')).toBe('Delete model provider?')
|
||||
b.locale.setLocale('zh')
|
||||
expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('模型')
|
||||
expect(injected().t('deleteTitle')).toBe('删除模型提供方?')
|
||||
})
|
||||
|
||||
it('locale change while the slot is undeclared stays a no-op', async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Section, setup-card, and hand-written editor behavior over a scripted wire face. */
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import Schema from 'schemastery'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
@@ -471,10 +471,28 @@ describe('ModelsSection', () => {
|
||||
await waitFor(() => { expect(set).toHaveBeenCalledTimes(1) })
|
||||
})
|
||||
|
||||
it('removes a user-added provider by unsetting its path', async () => {
|
||||
it('requires confirmation before removing a user-added provider', async () => {
|
||||
const { replace, mutate } = await mountSection()
|
||||
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
|
||||
const dialog = screen.getByRole('dialog', { name: en.deleteTitle })
|
||||
expect(dialog.textContent).toContain(en.deleteDescription)
|
||||
expect(document.activeElement).toBe(within(dialog).getByRole('button', { name: en.cancel }))
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: en.cancel }))
|
||||
expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull()
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
|
||||
fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle }))
|
||||
.getByRole('button', { name: en.close }))
|
||||
expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull()
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
|
||||
fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle }))
|
||||
.getByRole('button', { name: en.deleteConfirm }))
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
||||
expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull()
|
||||
expect(replace).not.toHaveBeenCalled()
|
||||
expect(mutate.mock.calls[0]?.[0]).toEqual({
|
||||
ns: 'llm-pi-ai',
|
||||
@@ -482,6 +500,28 @@ describe('ModelsSection', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks duplicate deletion while the confirmed removal is pending', async () => {
|
||||
let resolveRemoval!: (response: RpcResponse<SettingsNamespaceView>) => void
|
||||
const mutate = vi.fn(() => new Promise<RpcResponse<SettingsNamespaceView>>((resolve) => {
|
||||
resolveRemoval = resolve
|
||||
}))
|
||||
await mountSection({ mutate })
|
||||
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
|
||||
const dialog = screen.getByRole('dialog', { name: en.deleteTitle })
|
||||
const confirm = within(dialog).getByRole<HTMLButtonElement>('button', { name: en.deleteConfirm })
|
||||
fireEvent.click(confirm)
|
||||
fireEvent.click(confirm)
|
||||
expect(mutate).toHaveBeenCalledOnce()
|
||||
expect(confirm.disabled).toBe(true)
|
||||
expect(within(dialog).getByRole<HTMLButtonElement>('button', { name: en.cancel }).disabled).toBe(true)
|
||||
expect(within(dialog).getByRole('button', { name: en.deleting })).toBe(confirm)
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: en.close }))
|
||||
expect(screen.getByRole('dialog', { name: en.deleteTitle })).toBe(dialog)
|
||||
expect(mutate).toHaveBeenCalledOnce()
|
||||
await act(async () => { resolveRemoval(ok(wireNamespaces()[2]!)) })
|
||||
await waitFor(() => { expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull() })
|
||||
})
|
||||
|
||||
it('renders the load failure with a retry control', async () => {
|
||||
const face = scriptedFace()
|
||||
face.face.llm.providers = vi.fn(() => Promise.resolve(fail('directory down', 'internal'))) as never
|
||||
@@ -589,6 +629,8 @@ describe('ModelsSection', () => {
|
||||
// would appear — rather than the row silently staying put.
|
||||
await mountSection({ mutate: vi.fn(() => Promise.reject(new Error('the host refused'))) })
|
||||
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
|
||||
fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle }))
|
||||
.getByRole('button', { name: en.deleteConfirm }))
|
||||
await screen.findByText(`${en.loadFailed}: the host refused`)
|
||||
})
|
||||
|
||||
|
||||
13
packages/client/ui-models/tests/styles.spec.ts
Normal file
13
packages/client/ui-models/tests/styles.spec.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const css = readFileSync(fileURLToPath(new URL('../src/client/ModelsSection.module.css', import.meta.url)), 'utf8')
|
||||
|
||||
describe('ModelsSection theme styles', () => {
|
||||
it('uses the shared theme tokens without light-only fallbacks', () => {
|
||||
expect(css).not.toMatch(/var\(--(?:surface|text-|border|accent-strong)/)
|
||||
expect(css).toContain('background: var(--dsw-alias-bg-layer-3)')
|
||||
expect(css).toContain('color: var(--dsw-alias-label-primary)')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user