mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
feat(ui-settings-models): open provider-card and footer extension slots
The Models section now declares two SlotMap seats for out-of-tree plugins: settings.models.provider-card (keyed by the row's settingsNs, dispatched on saved cards, the first-run setup posture, and the add-provider draft, with the row view, configured join, and confirmed api-key state as owner props) and settings.models.footer (ordered list after the rows and add controls). Without registrants both seats render nothing. First consumer: the llm-pi-ai-oauth companion plugin's sign-in surface.
This commit is contained in:
+6
@@ -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/architecture/2026-08-26-models-page-extension-slots.md
|
||||
2026-08-26-models-page-extension-slots.md: e1ad30fd7283470b76d302508404d7c1445d5e50
|
||||
2026-08-26-models-page-extension-slots.zh.md: 4e7aa7cef0acbcf2e8344f31d4d458fde30c889c
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: Models-page extension slots
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-26-models-page-extension-slots.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Provider sign-in for the pi-ai catalog (GitHub Copilot, OpenAI accounts) is moving out of the product into an optional out-of-tree plugin for provider terms-of-service reasons. The plugin needs its sign-in button and attempt UI inside the Models page's provider cards — the surface where a user meets a provider — but `ui-settings-models` rendered its cards from closed code: the only integration path was editing this package, which an external plugin cannot do, and the page's one open seam (`settings.section`) can only add a whole separate page.
|
||||
|
||||
## Decision
|
||||
|
||||
`ui-settings-models` declares two SlotMap seats in `src/client/slot-contract.ts`, claims them as `children` of its `settings.section` registration, and re-exports their types from `./client` so an out-of-tree plugin can merge them with a type-only import.
|
||||
|
||||
`settings.models.provider-card` is `keyed` with `entryKey = ConfigurableProviderView.settingsNs`: one registration under an adapter family's settings namespace receives every card of that family — shipped catalog routes, adopted directory rows, and hand-declared routes alike — while the section never interprets the key. The key domain stays the open string space (no `keyProps` table) because hand-declared route ids are user-chosen at runtime. The seat dispatches on every card that shows a directory row: a saved row's card, its first-run setup posture, and the add-provider draft (its dormant row, `configured: false` in practice), which is where sign-in matters most — the user has just met the provider and holds no key. The hand-declared draft card has no directory row before saving and dispatches nothing. Owner props carry the row's `ConfigurableProviderView`, its `configured` join, and its confirmed api-key credential state (`keyConfigured`, which the first consumer uses to withhold sign-in beside a stored key); nothing more has a current consumer.
|
||||
|
||||
`settings.models.footer` is a `list` seat after the rows and the add controls, for section-level extension content such as orphaned-record management.
|
||||
|
||||
Without registrants both seats render nothing, so the shipped page is pixel-identical to before.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A `list` seat with self-filtering registrants instead of keying.** Every registrant would render (and return null) on every card, and two plugins could silently interleave UI inside one family's cards. Keying by namespace gives one accountable extension owner per adapter family and zero wasted dispatches, and reuses the exact pairing rationale of `settings.plugin.item`.
|
||||
|
||||
**Keying by provider route id.** Route ids are dynamic — hand-declared routes are named by users at runtime — so a plugin could not register ahead of the rows it wants and would have to churn registrations as the directory changes.
|
||||
|
||||
**A `chain` seat replacing the whole card body.** No current consumer needs to replace the editor; the sign-in surface is additive. A takeover contract would also make the section's layout a compatibility surface. A chain can still be added later without disturbing these seats.
|
||||
|
||||
**Keeping the sign-in UI wired inside `ui-settings-models` (the pre-plugin design).** Ships the terms-of-service-sensitive surface in the product, which is the outcome this extension point exists to avoid.
|
||||
|
||||
## Consequences
|
||||
|
||||
An out-of-tree plugin can now integrate per-family card UI into the Models page with no product edits; `llm-pi-ai-oauth` is the first consumer. The cost is a public contract: `ProviderCardExtrasOwnerProps` exposes `ConfigurableProviderView` at the `./client` boundary, and the dispatch sites (saved card, setup posture, add draft, footer) become behavior extensions rely on. One extension owner per adapter family is a deliberate limit — a second plugin claiming the same namespace is refused by the keyed registry rather than silently merged.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: Models 页扩展插槽
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-26-models-page-extension-slots.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
出于提供方服务条款的考虑,pi-ai catalog 的提供方登录(GitHub Copilot、OpenAI 账号)正从产品中移出,改由一个可选的仓库外插件承担。该插件需要把登录按钮与登录过程 UI 放进 Models 页的提供方卡片——用户与提供方相遇的界面——但 `ui-settings-models` 的卡片由封闭代码渲染:唯一的集成途径是修改本包,外部插件做不到;页面仅有的开放缝隙(`settings.section`)只能新增一整个独立页面。
|
||||
|
||||
## Decision
|
||||
|
||||
`ui-settings-models` 在 `src/client/slot-contract.ts` 声明两个 SlotMap 席位,在其 `settings.section` 注册中以 `children` 认领它们,并从 `./client` 再导出其类型,使仓库外插件通过 type-only import 即可获得类型合并。
|
||||
|
||||
`settings.models.provider-card` 为 `keyed`,`entryKey = ConfigurableProviderView.settingsNs`:以某适配器家族的 settings namespace 注册一次,即可收到该家族的全部卡片——内置 catalog 路由、从目录采纳的行、手工声明的路由一视同仁——而分区从不解释这个 key。键域保持开放字符串空间(不设 `keyProps` 表),因为手工声明的路由 id 由用户在运行时命名。该席位在每张展示目录行的卡片上分发:已保存行的卡片、其首次运行 setup 形态、以及「添加提供方」草稿卡(其休眠行,实际为 `configured: false`)——草稿卡正是登录价值最大的时刻:用户刚遇到该提供方、手中还没有密钥。手工声明的草稿卡在保存前没有目录行,不分发。Owner props 携带该行的 `ConfigurableProviderView`、其 `configured` 合并结果与已确认的 api-key 凭据状态(`keyConfigured`,首个消费者用它在已存密钥旁抑制登录入口);更多字段没有现役消费者。
|
||||
|
||||
`settings.models.footer` 为 `list` 席位,位于行列表与新增控件之后,承载孤儿记录管理这类分区级扩展内容。
|
||||
|
||||
没有注册方时两个席位均不渲染,产品页面与之前逐像素一致。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**用 `list` 席位、由注册方自行筛选,替代按键分发。**每个注册方都会在每张卡片上渲染(再返回 null),且两个插件可能在同一家族的卡片里静默交错 UI。按 namespace 分发让每个适配器家族有唯一可问责的扩展所有者、零浪费分发,并完全复用 `settings.plugin.item` 的配对理由。
|
||||
|
||||
**按提供方路由 id 分发。**路由 id 是动态的——手工声明的路由由用户在运行时命名——插件无法先于目标行注册,还得随目录变化反复重注册。
|
||||
|
||||
**用 `chain` 席位整体接管卡片。**没有现役消费者需要替换编辑器;登录界面是加法。接管契约还会让分区布局成为兼容面。将来仍可在不动这两个席位的前提下追加 chain。
|
||||
|
||||
**把登录 UI 继续织在 `ui-settings-models` 里(插件化之前的设计)。**会把服务条款敏感的界面随产品发布,而这正是本扩展点要避免的结果。
|
||||
|
||||
## Consequences
|
||||
|
||||
仓库外插件现在无需改动产品即可把按家族的卡片 UI 集成进 Models 页;`llm-pi-ai-oauth` 是首个消费者。代价是一份公开契约:`ProviderCardExtrasOwnerProps` 在 `./client` 边界暴露 `ConfigurableProviderView`,且各分发位点(已保存卡片、setup 形态、新增草稿、footer)成为扩展方依赖的行为。每个适配器家族只有一个扩展所有者是刻意的限制——第二个插件认领同一 namespace 会被 keyed 注册表拒绝,而不是被静默合并。
|
||||
@@ -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-settings-models/README.md
|
||||
README.md: c1d2e08a32d10f6d0e2bb9bb13e085d69d6ece92
|
||||
README.zh.md: 0204494c7856b96d6f0c17c580abea31e49c3516
|
||||
README.md: 6ed2bde147f7c9d3853196fa023d7461f977b2da
|
||||
README.zh.md: 3806244d4deec80a31d9c4bafc6a11aaddbb528f
|
||||
|
||||
@@ -47,6 +47,10 @@ The add flow is a card carrying the dormant-directory provider select — a bare
|
||||
|
||||
After the versioned notice step completes, the DeepSeek step projects first-run readiness from the same joined snapshot. ANY provider the user can already reach ends it without rendering; only a user with none is asked for the official DeepSeek key. Configure later completes only this coordinator pass, and an absent adapter, inactive route, failed join, read-only deployment, or unusable capability completes the step without rendering — Models remains the diagnostic surface.
|
||||
|
||||
### Extension slots
|
||||
|
||||
The section declares two seats for plugins distributed outside this repository, typed in [`src/client/slot-contract.ts`](src/client/slot-contract.ts) and exported from `./client`. `settings.models.provider-card` (keyed) renders inside every card that shows a directory row — a saved row's card, its first-run setup posture, and the add-provider draft — dispatched with `entryKey = settingsNs` and owner props carrying the row's `ConfigurableProviderView`, its configured state, and its confirmed api-key credential state, so one registration under an adapter family's namespace receives every card of that family, hand-declared routes included; the hand-declared draft card has no directory row yet and dispatches nothing until saved. `settings.models.footer` (list) renders after the rows and the add controls. A registrant activates through `ctx.slots.inject` with a type-only import of this package's `/client` entry; without registrants both seats render nothing.
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
|
||||
@@ -47,6 +47,10 @@ kind: "package-reference"
|
||||
|
||||
版本化声明步骤完成后,DeepSeek 步骤从同一份合并快照投影首次运行就绪状态。用户已经能够到达的**任何**提供方都会直接结束该步骤、不做渲染;只有没有任何提供方的用户才会被询问官方 DeepSeek 密钥。「稍后配置」只完成这次协调器遍历;适配器缺失、路由不活动、合并失败、只读部署或能力不可用时,该步骤不渲染即完成——Models 仍是诊断界面。
|
||||
|
||||
### 扩展插槽
|
||||
|
||||
本分区为仓库外分发的插件声明两个席位,类型定义在 [`src/client/slot-contract.ts`](src/client/slot-contract.ts) 并从 `./client` 导出。`settings.models.provider-card`(keyed)渲染在每张展示目录行的卡片内部——已保存行的卡片、其首次运行 setup 形态、以及「添加提供方」草稿卡——以 `entryKey = settingsNs` 分发,owner props 携带该行的 `ConfigurableProviderView`、其 configured 状态与已确认的 api-key 凭据状态,因此以某适配器家族的 namespace 注册一次即可收到该家族的全部卡片,含手工声明的路由;手工声明的草稿卡尚无目录行,保存之前不分发。`settings.models.footer`(list)渲染在行列表与新增控件之后。注册方通过 `ctx.slots.inject` 激活,并以 type-only import 引入本包 `/client` 入口;没有注册方时两个席位均不渲染任何内容。
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
|
||||
@@ -16,7 +16,9 @@ import { useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { Button, IconPlusOutline16, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { InjectFace, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
// Type-only: pulls this package's SlotMap merge (the two Models child slots).
|
||||
import type {} from './slot-contract.ts'
|
||||
import { CustomProviderCard } from './CustomProviderCard.tsx'
|
||||
import { deriveKeyRef, messageOf, protocolChoices, providerUsable } from './store.ts'
|
||||
import type { ModelsSettingsStore, ProviderRow } from './store.ts'
|
||||
@@ -42,11 +44,18 @@ export interface ModelsSectionInjected {
|
||||
t: (key: keyof typeof en) => string
|
||||
}
|
||||
|
||||
/** The child slots this section declares and dispatches (see ./slot-contract.ts). */
|
||||
type ModelsChildSlots = 'settings.models.provider-card' | 'settings.models.footer'
|
||||
|
||||
/** The child-slot dispatch function the renderer binds for the section. */
|
||||
type ModelsRenderSlot = PropsRenderSlots<ModelsChildSlots>['renderSlot']
|
||||
|
||||
/**
|
||||
* Props delivered by the slot outlet: the inject face spread flat (the
|
||||
* renderer erases the share boundary at the render call).
|
||||
* renderer erases the share boundary at the render call) plus the child-slot
|
||||
* dispatch seat.
|
||||
*/
|
||||
export type ModelsSectionProps = Partial<InjectFace<ModelsSectionInjected>>
|
||||
export type ModelsSectionProps = Partial<InjectFace<ModelsSectionInjected>> & Partial<PropsRenderSlots<ModelsChildSlots>>
|
||||
|
||||
type ModelsSectionFace = InjectFace<ModelsSectionInjected>
|
||||
|
||||
@@ -139,6 +148,11 @@ export function needsSetup(row: ProviderRow, anyUsable: boolean): boolean {
|
||||
return row.credential?.configured !== true
|
||||
}
|
||||
|
||||
/** The provider-card seat's credential fact: a referenced key confirmed configured. */
|
||||
function keyConfiguredOf(row: ProviderRow): boolean {
|
||||
return row.apiKeyEnv !== undefined && row.credential?.configured === true
|
||||
}
|
||||
|
||||
function targetOf(row: ProviderRow): EditorTarget {
|
||||
const managedRef = deriveKeyRef(row.entry.provider)
|
||||
const credentialRef = row.apiKeyEnv === managedRef
|
||||
@@ -175,15 +189,15 @@ export function providerCopy(template: string, target: ProviderIdentity): string
|
||||
* @returns the section, or null while the shell has not injected yet.
|
||||
*/
|
||||
export function ModelsSection(props: ModelsSectionProps): ReactNode {
|
||||
const { controller, useSnapshot, api, schema, t } = props
|
||||
const { controller, useSnapshot, api, schema, t, renderSlot } = props
|
||||
if (
|
||||
controller === undefined || useSnapshot === undefined || api === undefined
|
||||
|| schema === undefined || t === undefined
|
||||
|| schema === undefined || t === undefined || renderSlot === undefined
|
||||
) return null
|
||||
return <Loaded injected={{ controller, useSnapshot, api, schema, t }} />
|
||||
return <Loaded injected={{ controller, useSnapshot, api, schema, t }} renderSlot={renderSlot} />
|
||||
}
|
||||
|
||||
function Loaded({ injected }: { injected: ModelsSectionFace }): ReactNode {
|
||||
function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderSlot: ModelsRenderSlot }): ReactNode {
|
||||
const { controller, api, schema, t } = injected
|
||||
const state = injected.useSnapshot(snapshot => snapshot)
|
||||
const [editing, setEditing] = useState<EditorTarget | undefined>(undefined)
|
||||
@@ -275,6 +289,12 @@ function Loaded({ injected }: { injected: ModelsSectionFace }): ReactNode {
|
||||
const addable = state.rows.filter(row => !row.configured && row.entry.settingsNs !== '')
|
||||
const addTarget = adding ? editing : undefined
|
||||
const addNamespace = addTarget === undefined ? undefined : state.namespaces.get(addTarget.settingsNs)
|
||||
// The draft's directory row, for the card extension seat. A refresh can drop
|
||||
// the row mid-draft (the route was adopted or withdrawn elsewhere); the
|
||||
// draft card stays while the seat simply has no row to dispatch.
|
||||
const addRow = addTarget === undefined
|
||||
? undefined
|
||||
: state.rows.find(row => row.entry.provider === addTarget.provider)
|
||||
// Hand-declared routes live in the pi-ai namespace, which is also the only
|
||||
// one whose schema names the protocols one may speak; without it mounted
|
||||
// there is nothing to declare and the entry point stays disabled.
|
||||
@@ -324,6 +344,11 @@ function Loaded({ injected }: { injected: ModelsSectionFace }): ReactNode {
|
||||
readOnly: !state.writable,
|
||||
onClose: (changed) => { closeSetup(changed, target) },
|
||||
})}
|
||||
{renderSlot(
|
||||
'settings.models.provider-card',
|
||||
{ provider: row.entry, configured: row.configured, keyConfigured: keyConfiguredOf(row) },
|
||||
{ entryKey: row.entry.settingsNs },
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -399,6 +424,11 @@ function Loaded({ injected }: { injected: ModelsSectionFace }): ReactNode {
|
||||
: null}
|
||||
</span>
|
||||
</div>
|
||||
{renderSlot(
|
||||
'settings.models.provider-card',
|
||||
{ provider: row.entry, configured: row.configured, keyConfigured: keyConfiguredOf(row) },
|
||||
{ entryKey: row.entry.settingsNs },
|
||||
)}
|
||||
{open
|
||||
? renderProviderEditor({
|
||||
target,
|
||||
@@ -449,6 +479,13 @@ function Loaded({ injected }: { injected: ModelsSectionFace }): ReactNode {
|
||||
readOnly={!state.writable}
|
||||
onClose={(changed) => { closeEditor(changed, addTarget) }}
|
||||
/>
|
||||
{addRow === undefined
|
||||
? null
|
||||
: renderSlot(
|
||||
'settings.models.provider-card',
|
||||
{ provider: addRow.entry, configured: addRow.configured, keyConfigured: keyConfiguredOf(addRow) },
|
||||
{ entryKey: addRow.entry.settingsNs },
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
: declaring
|
||||
@@ -509,6 +546,7 @@ function Loaded({ injected }: { injected: ModelsSectionFace }): ReactNode {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{renderSlot('settings.models.footer', {})}
|
||||
<Modal
|
||||
open={deleteTarget !== undefined}
|
||||
onClose={closeDelete}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { en, zh, type ModelsKey } from './locales.ts'
|
||||
import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../onboarding-copy.ts'
|
||||
|
||||
export type { ModelsSectionInjected, ModelsSectionProps } from './ModelsSection.tsx'
|
||||
export type { ModelsFooterOwnerProps, ProviderCardExtrasOwnerProps } from './slot-contract.ts'
|
||||
export type { ModelsKey } from './locales.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
@@ -125,6 +126,10 @@ export function apply(ctx: ClientContext): void {
|
||||
order: 10,
|
||||
label: () => t('nav'),
|
||||
inject: injected,
|
||||
children: {
|
||||
'settings.models.provider-card': { kind: 'keyed', scope: 'root' },
|
||||
'settings.models.footer': { kind: 'list', scope: 'root' },
|
||||
},
|
||||
}, ModelsSection))
|
||||
ctx.slots.inject('settings.onboarding', () => ctx.slots.register({
|
||||
name: 'settings.onboarding',
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Models-page extension slots — the two seats through which a plugin
|
||||
* distributed outside this repository adds UI to the Models settings section
|
||||
* without editing it.
|
||||
*
|
||||
* `settings.models.provider-card` is keyed by the row's owning settings
|
||||
* namespace (`ConfigurableProviderView.settingsNs`): an adapter family's
|
||||
* companion plugin registers one entry under the family's namespace and
|
||||
* receives every card of that family — shipped, added, and hand-declared rows
|
||||
* alike — while the section never learns what the namespace means. Keying on
|
||||
* the namespace follows `settings.plugin.item`, and the key domain stays the
|
||||
* open string space because hand-declared route ids are user-chosen at
|
||||
* runtime.
|
||||
*
|
||||
* TYPE HOME RATIONALE: the Models section declares these slots at runtime,
|
||||
* and a plugin registering an extension already depends on this package for
|
||||
* the declaration. The types therefore live with their declarer.
|
||||
*/
|
||||
|
||||
import type { ConfigurableProviderView } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
/**
|
||||
* One provider card's adapter extension area, dispatched with
|
||||
* `entryKey = settingsNs` on every card that renders a directory row: a
|
||||
* saved row's card (its first-run setup posture included) and the
|
||||
* add-provider draft card. The hand-declared draft card has no directory
|
||||
* row yet, so it dispatches nothing until saved. Without a registrant the
|
||||
* area renders nothing.
|
||||
*/
|
||||
'settings.models.provider-card': { kind: 'keyed'; scope: 'root'; owner: ProviderCardExtrasOwnerProps }
|
||||
/**
|
||||
* Ordered extension area after the provider rows and the add controls.
|
||||
* Without a registrant the area renders nothing.
|
||||
*/
|
||||
'settings.models.footer': { kind: 'list'; scope: 'root'; owner: ModelsFooterOwnerProps }
|
||||
}
|
||||
}
|
||||
|
||||
/** Owner share of one provider-card extension occurrence. */
|
||||
export interface ProviderCardExtrasOwnerProps {
|
||||
/** The card's directory row (route id, display name, settings address, live state). */
|
||||
provider: ConfigurableProviderView
|
||||
/** Whether any layer configures this provider (its profile resolves); `false` while the add-provider draft edits a dormant row. */
|
||||
configured: boolean
|
||||
/** Whether the row's referenced api-key credential is confirmed configured (the page's credential join). */
|
||||
keyConfigured: boolean
|
||||
}
|
||||
|
||||
/** Owner share of the footer area (the section supplies nothing). */
|
||||
export interface ModelsFooterOwnerProps {
|
||||
/** Marker field: footer owner props are intentionally empty. */
|
||||
children?: never
|
||||
}
|
||||
@@ -61,6 +61,9 @@ describe('ui-settings-models apply', () => {
|
||||
const entry = before.slots.entries('settings.section')[0]!
|
||||
expect(entry.component).toBe(ModelsSection)
|
||||
expect(entry.options).toMatchObject({ id: 'models', order: 10 })
|
||||
// The section claims its two extension seats in the same registration.
|
||||
expect(before.slots.spec('settings.models.provider-card')).toMatchObject({ kind: 'keyed', scope: 'root' })
|
||||
expect(before.slots.spec('settings.models.footer')).toMatchObject({ kind: 'list', scope: 'root' })
|
||||
// The nav label is a locale-following thunk; owners resolve at read time.
|
||||
expect(resolveSlotLabel(entry.options.label)).toBe('模型')
|
||||
const injected = (entry.inject as unknown as () => import('../src/client/ModelsSection.tsx').ModelsSectionInjected)()
|
||||
@@ -137,6 +140,28 @@ describe('ui-settings-models apply', () => {
|
||||
b.locale.setLocale('zh')
|
||||
})
|
||||
|
||||
it('accepts extension entries under the declared seats and cascades them with the declarer', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots)
|
||||
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
// A keyed card extension and a footer entry register through the ordinary
|
||||
// ledger once the section's registration declared the seats.
|
||||
const disposeCard = b.slots.register(
|
||||
{ name: 'settings.models.provider-card', key: 'llm-pi-ai' } as never,
|
||||
() => null,
|
||||
)
|
||||
b.slots.register({ name: 'settings.models.footer', id: 'extra', order: 0 } as never, () => null)
|
||||
expect(b.slots.entries('settings.models.provider-card')).toHaveLength(1)
|
||||
expect(b.slots.entries('settings.models.footer')).toHaveLength(1)
|
||||
// Extension-side HMR safety: its own disposer removes the entry.
|
||||
disposeCard()
|
||||
expect(b.slots.entries('settings.models.provider-card')).toHaveLength(0)
|
||||
// Declarer unload cascades whatever extension entries remain.
|
||||
await fiber.dispose()
|
||||
expect(b.slots.entries('settings.models.footer')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('registers the zh/en nav dictionaries and disposes everything with the fiber', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots)
|
||||
|
||||
@@ -195,20 +195,44 @@ function scriptedFace(overrides: {
|
||||
|
||||
type WireFace = ConstructorParameters<typeof ModelsSettingsStore>[0]
|
||||
|
||||
/** One recorded child-slot dispatch: seat name, owner share, kind options. */
|
||||
type RenderSlotCall = [name: string, owner: Record<string, unknown>, opts?: { entryKey?: string }]
|
||||
|
||||
/** Child-slot dispatch stub: records every seat occurrence, renders nothing. */
|
||||
function stubRenderSlot() {
|
||||
return vi.fn((..._call: RenderSlotCall) => null)
|
||||
}
|
||||
|
||||
/** The provider-card seat dispatches a stub recorded, as (route id, configured, keyConfigured, entryKey). */
|
||||
function cardSeatCalls(
|
||||
renderSlot: ReturnType<typeof stubRenderSlot>,
|
||||
): Array<[string, boolean, boolean, string | undefined]> {
|
||||
return renderSlot.mock.calls
|
||||
.filter(call => call[0] === 'settings.models.provider-card')
|
||||
.map(call => [
|
||||
(call[1] as { provider: { provider: string } }).provider.provider,
|
||||
(call[1] as { configured: boolean }).configured,
|
||||
(call[1] as { keyConfigured: boolean }).keyConfigured,
|
||||
call[2]?.entryKey,
|
||||
])
|
||||
}
|
||||
|
||||
async function mountFace(scripted: ReturnType<typeof scriptedFace>) {
|
||||
const { face, update, replace, mutate, set, unset } = scripted
|
||||
const mirror = new SettingsDescribeMirror(face as never)
|
||||
const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema, mirror)
|
||||
await controller.load()
|
||||
const renderSlot = stubRenderSlot()
|
||||
const injected: ModelsSectionProps = {
|
||||
controller,
|
||||
useSnapshot: bindSnapshotSelector(controller.store),
|
||||
api: face as never,
|
||||
schema: settingsSchema,
|
||||
t,
|
||||
renderSlot: renderSlot as unknown as NonNullable<ModelsSectionProps['renderSlot']>,
|
||||
}
|
||||
const view = render(<ModelsSection {...injected} />)
|
||||
return { view, face, update, replace, mutate, set, unset, controller, mirror }
|
||||
return { view, face, update, replace, mutate, set, unset, controller, mirror, renderSlot }
|
||||
}
|
||||
|
||||
async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {}) {
|
||||
@@ -246,6 +270,45 @@ describe('ModelsSection', () => {
|
||||
expect(document.body.textContent).toBe('')
|
||||
})
|
||||
|
||||
it('dispatches the provider-card seat per rendered row, keyed by the owning namespace', async () => {
|
||||
const { renderSlot } = await mountSection()
|
||||
const cards = cardSeatCalls(renderSlot)
|
||||
expect(cards).toContainEqual(['openai', true, true, 'llm-pi-ai'])
|
||||
expect(cards).toContainEqual(['deepseek-official', true, false, 'llm-deepseek'])
|
||||
// The footer seat renders once below the rows and the add controls.
|
||||
expect(renderSlot.mock.calls.filter(call => call[0] === 'settings.models.footer')).toEqual([
|
||||
['settings.models.footer', {}],
|
||||
])
|
||||
})
|
||||
|
||||
it('dispatches the provider-card seat inside the first-run setup card', async () => {
|
||||
const { renderSlot } = await mountFirstRun()
|
||||
expect(cardSeatCalls(renderSlot)).toContainEqual(['deepseek-official', true, false, 'llm-deepseek'])
|
||||
})
|
||||
|
||||
it('dispatches the provider-card seat on the add-provider draft with its dormant row', async () => {
|
||||
const { renderSlot } = await mountSection()
|
||||
renderSlot.mockClear()
|
||||
fireEvent.click(screen.getByRole('button', { name: en.add }))
|
||||
expect(cardSeatCalls(renderSlot)).toContainEqual(['anthropic', false, false, 'llm-pi-ai'])
|
||||
})
|
||||
|
||||
it('skips the draft seat when a refresh drops the dormant row', async () => {
|
||||
const { renderSlot, face, controller } = await mountSection()
|
||||
fireEvent.click(screen.getByRole('button', { name: en.add }))
|
||||
face.llm.providers.mockImplementation(() => Promise.resolve(ok({
|
||||
providers: [
|
||||
{ provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true },
|
||||
{ provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true },
|
||||
],
|
||||
})))
|
||||
renderSlot.mockClear()
|
||||
await act(async () => { await controller.load() })
|
||||
// The draft card is still open while its row is gone from the directory.
|
||||
expect(screen.getByLabelText(en.keyInput)).toBeTruthy()
|
||||
expect(cardSeatCalls(renderSlot).some(([provider]) => provider === 'anthropic')).toBe(false)
|
||||
})
|
||||
|
||||
it('persists the default-off subagent model-selection switch for new sessions', async () => {
|
||||
const enabledNamespace: SettingsNamespaceView = {
|
||||
...wireNamespaces().find(view => view.ns === 'subagent-model-selection')!,
|
||||
@@ -352,6 +415,7 @@ describe('ModelsSection', () => {
|
||||
api={face as never}
|
||||
schema={settingsSchema}
|
||||
t={t}
|
||||
renderSlot={stubRenderSlot() as unknown as NonNullable<ModelsSectionProps['renderSlot']>}
|
||||
/>)
|
||||
|
||||
const missing = screen.getByRole('img', { name: en.credentialMissing })
|
||||
@@ -376,6 +440,7 @@ describe('ModelsSection', () => {
|
||||
api={face as never}
|
||||
schema={settingsSchema}
|
||||
t={t}
|
||||
renderSlot={stubRenderSlot() as unknown as NonNullable<ModelsSectionProps['renderSlot']>}
|
||||
/>)
|
||||
// Now a row with an Edit button, not an open card.
|
||||
expect(screen.getAllByText(en.edit).length).toBeGreaterThan(1)
|
||||
@@ -1106,6 +1171,7 @@ describe('ModelsSection', () => {
|
||||
api={face as never}
|
||||
schema={settingsSchema}
|
||||
t={t}
|
||||
renderSlot={stubRenderSlot() as unknown as NonNullable<ModelsSectionProps['renderSlot']>}
|
||||
/>)
|
||||
const key = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
|
||||
expect(key.placeholder).toBe(en.keyPlaceholder)
|
||||
@@ -1245,6 +1311,7 @@ describe('ModelsSection', () => {
|
||||
api={face.face as never}
|
||||
schema={settingsSchema}
|
||||
t={t}
|
||||
renderSlot={stubRenderSlot() as unknown as NonNullable<ModelsSectionProps['renderSlot']>}
|
||||
/>)
|
||||
expect(screen.getByText(/directory down/)).toBeTruthy()
|
||||
fireEvent.click(screen.getByText(en.retry))
|
||||
@@ -1267,6 +1334,7 @@ describe('ModelsSection', () => {
|
||||
api={face as never}
|
||||
schema={settingsSchema}
|
||||
t={t}
|
||||
renderSlot={stubRenderSlot() as unknown as NonNullable<ModelsSectionProps['renderSlot']>}
|
||||
/>)
|
||||
expect(screen.getByText(en.readOnly)).toBeTruthy()
|
||||
expect(screen.getAllByText<HTMLButtonElement>(en.remove).every(button => button.disabled)).toBe(true)
|
||||
@@ -1328,6 +1396,7 @@ describe('ModelsSection', () => {
|
||||
api={face as never}
|
||||
schema={settingsSchema}
|
||||
t={t}
|
||||
renderSlot={stubRenderSlot() as unknown as NonNullable<ModelsSectionProps['renderSlot']>}
|
||||
/>)
|
||||
await screen.findByText('DeepSeek')
|
||||
})
|
||||
|
||||
@@ -150,6 +150,7 @@ async function mountSection(options: Parameters<typeof scriptedFace>[0] = {}) {
|
||||
api: scripted.face as never,
|
||||
schema: settingsSchema,
|
||||
t,
|
||||
renderSlot: () => null,
|
||||
}
|
||||
render(<ModelsSection {...injected} />)
|
||||
return { ...scripted, controller }
|
||||
|
||||
@@ -1558,6 +1558,86 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.header\', () => ctx.slots.register(\n { name: \'settings.header\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-settings/src/client/contract/slots.ts:30',
|
||||
},
|
||||
{
|
||||
key: 'settings.models.footer',
|
||||
kind: 'list',
|
||||
scope: 'root',
|
||||
summary: 'Ordered extension area after the provider rows and the add controls.',
|
||||
doc: 'Ordered extension area after the provider rows and the add controls.\nWithout a registrant the area renders nothing.',
|
||||
registerOptions: [
|
||||
{
|
||||
name: 'id',
|
||||
requirement: 'required',
|
||||
type: 'string',
|
||||
doc: 'Your cell key. Use an id of your own: a fresh id is added beside the shipped entries, while reusing a shipped id puts you in THAT cell and replaces it. Owners that filter by id address you by it.',
|
||||
},
|
||||
{
|
||||
name: 'order',
|
||||
requirement: 'optional',
|
||||
type: 'number',
|
||||
doc: 'Position among the entries, ascending (default 0).',
|
||||
},
|
||||
{
|
||||
name: 'label',
|
||||
requirement: 'optional',
|
||||
type: 'string | (() => string)',
|
||||
doc: 'Display text where the owner projects one (nav rows, tabs). A thunk is re-read on every projection, so localized text follows the active locale without re-registering.',
|
||||
},
|
||||
],
|
||||
ownerProps: [
|
||||
'/** Owner share of the footer area (the section supplies nothing). */\nexport interface ModelsFooterOwnerProps {\n /** Marker field: footer owner props are intentionally empty. */\n children?: never\n}',
|
||||
],
|
||||
ownerPropsReferences: [],
|
||||
standardProps: [
|
||||
'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
|
||||
'useSessions: UseSessions',
|
||||
'useSessionPendingInteraction: UseSessionPendingInteraction',
|
||||
'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
|
||||
],
|
||||
keyDomain: '',
|
||||
hookContext: '',
|
||||
slotInject: '',
|
||||
declaredBy: 'an entry in \'settings.section\' (client-ui-settings-models), so it exists while that entry is mounted',
|
||||
occupants: [],
|
||||
replaceRisk: 'none',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.models.footer\', () => ctx.slots.register(\n { name: \'settings.models.footer\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-settings-models/src/client/slot-contract.ts:38',
|
||||
},
|
||||
{
|
||||
key: 'settings.models.provider-card',
|
||||
kind: 'keyed',
|
||||
scope: 'root',
|
||||
summary: 'One provider card\'s adapter extension area, dispatched with `entryKey = settingsNs` on every card that renders a directory row: a saved row\'s card (its first-run setup posture included) and the add-provider draft card.',
|
||||
doc: 'One provider card\'s adapter extension area, dispatched with\n`entryKey = settingsNs` on every card that renders a directory row: a\nsaved row\'s card (its first-run setup posture included) and the\nadd-provider draft card. The hand-declared draft card has no directory\nrow yet, so it dispatches nothing until saved. Without a registrant the\narea renders nothing.',
|
||||
registerOptions: [
|
||||
{
|
||||
name: 'key',
|
||||
requirement: 'required',
|
||||
type: 'string',
|
||||
doc: 'Your cell key: the entry renders where the owner dispatches this exact key. Registering an already-occupied key replaces that occupant.',
|
||||
},
|
||||
],
|
||||
ownerProps: [
|
||||
'/** Owner share of one provider-card extension occurrence. */\nexport interface ProviderCardExtrasOwnerProps {\n /** The card\'s directory row (route id, display name, settings address, live state). */\n provider: ConfigurableProviderView\n /** Whether any layer configures this provider (its profile resolves); `false` while the add-provider draft edits a dormant row. */\n configured: boolean\n /** Whether the row\'s referenced api-key credential is confirmed configured (the page\'s credential join). */\n keyConfigured: boolean\n}',
|
||||
],
|
||||
ownerPropsReferences: [
|
||||
'ConfigurableProviderView',
|
||||
],
|
||||
standardProps: [
|
||||
'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
|
||||
'useSessions: UseSessions',
|
||||
'useSessionPendingInteraction: UseSessionPendingInteraction',
|
||||
'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
|
||||
],
|
||||
keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet',
|
||||
hookContext: '',
|
||||
slotInject: '',
|
||||
declaredBy: 'an entry in \'settings.section\' (client-ui-settings-models), so it exists while that entry is mounted',
|
||||
occupants: [],
|
||||
replaceRisk: 'none',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.models.provider-card\', () => ctx.slots.register(\n { name: \'settings.models.provider-card\', key: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-settings-models/src/client/slot-contract.ts:33',
|
||||
},
|
||||
{
|
||||
key: 'settings.onboarding',
|
||||
kind: 'list',
|
||||
|
||||
Reference in New Issue
Block a user