From 3c8cf3a564097a1536218e5c5c8151ed5734ba56 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 11 Aug 2026 18:00:24 +0800 Subject: [PATCH] fix(client-ui-plugin-config): refresh the key badge when the Host reports the credential changed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card read the credential only when its settings scope published, and a credential is not part of any settings section: a key written from the Models page — which addresses the same reference — left this badge reporting a state the Host had already replaced. It now re-reads on credentials/changed for the reference it watches, and ignores the event for any other reference. --- .../client/ui-plugin-config/README.i18n.yaml | 4 +-- packages/client/ui-plugin-config/README.md | 2 ++ packages/client/ui-plugin-config/README.zh.md | 2 ++ .../ui-plugin-config/src/client/index.ts | 8 +++++ .../src/client/web-search-store.ts | 13 ++++++++ .../ui-plugin-config/tests/apply.spec.ts | 32 +++++++++++++++++-- .../ui-plugin-config/tests/stores.spec.ts | 24 ++++++++++++++ 7 files changed, 81 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-plugin-config/README.i18n.yaml b/packages/client/ui-plugin-config/README.i18n.yaml index ea6b60fb95..ccd91999c5 100644 --- a/packages/client/ui-plugin-config/README.i18n.yaml +++ b/packages/client/ui-plugin-config/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-plugin-config/README.md -README.md: 9297379a940d004acc27e2dcb1bb879fa2142f30 -README.zh.md: 2b3d73bc6e1ccd72cad9fe86acb357c2ab269a41 +README.md: cd5ea70e7ca8e126418b73447c7ef2f1aed94b4d +README.zh.md: 2859fa9b92d622ff36eb71f099289509fcd84fec diff --git a/packages/client/ui-plugin-config/README.md b/packages/client/ui-plugin-config/README.md index 9297379a94..cd5ea70e7c 100644 --- a/packages/client/ui-plugin-config/README.md +++ b/packages/client/ui-plugin-config/README.md @@ -20,6 +20,8 @@ A card stages what the user types and writes it only when they save. Each contro Saving writes each staged field through the client settings scope, which fences every write with the namespace revision it read, so a form that has drifted from the document is refused rather than overwriting a concurrent change. The Host is the only authority on whether a value was accepted — its validators own the constraints no schema can express — so the card reads the section back afterwards and reports a save that did not land, keeping those drafts for the user to correct. +A key can also be written from another surface — the Models page addresses the same reference — which changes no settings section, so the card re-reads on the Host's credential-changed signal for the reference it watches. + A field's presence in the raw user layer — not its value — is what marks it overridden; a reset clears that field so it re-inherits the composition layer. Secret-role fields never ride a response, so a key control starts blank, reports only whether one is configured, and writes through the credentials domain rather than the settings section; a blank draft writes nothing and keeps the stored key. ## Model Experience diff --git a/packages/client/ui-plugin-config/README.zh.md b/packages/client/ui-plugin-config/README.zh.md index 2b3d73bc6e..2859fa9b92 100644 --- a/packages/client/ui-plugin-config/README.zh.md +++ b/packages/client/ui-plugin-config/README.zh.md @@ -20,6 +20,8 @@ 保存时,每个暂存字段都通过客户端 settings scope 写入,该 scope 用读取时的命名空间 revision 为每次写入设栅,因此已与文档脱节的表单会被拒绝,而不是覆盖并发变更。某个值是否被接受只有 Host 说了算——schema 表达不了的约束归它的校验器所有——因此卡片在写入后回读分节,报告没有落盘的保存,并保留这些草稿供用户修改。 +密钥也可能从别的表层写入——模型页寻址的是同一个引用——而那不改变任何 settings 分节,因此卡片会在 Host 报告它所关注的引用发生变化时重读。 + 字段是否被覆盖,取决于它是否出现在原始用户层中,而非取决于它的值;重置会清除该字段,使其重新继承组装层。secret 角色的字段绝不搭乘响应,因此密钥控件初始为空、只报告是否已配置,并经由 credentials 领域而非 settings 分节写入;空草稿不写入任何东西,保留已存密钥。 ## 模型体验 diff --git a/packages/client/ui-plugin-config/src/client/index.ts b/packages/client/ui-plugin-config/src/client/index.ts index d49728998b..6680024308 100644 --- a/packages/client/ui-plugin-config/src/client/index.ts +++ b/packages/client/ui-plugin-config/src/client/index.ts @@ -55,6 +55,14 @@ export function apply(ctx: ClientContext): void { const agentLoop = new AgentLoopCardController(bindSettingsScope(ctx, { namespace: AGENT_LOOP_NS })) const webSearch = new WebSearchCardController(bindSettingsScope(ctx, { namespace: WEB_SEARCH_NS }), api) + // The credential a card reports is not part of any settings section, so its + // scope publishes nothing when one is written. This is the only signal that + // a key written on another surface reached the Host. + ctx.effect( + () => ctx.on('credentials/changed', (ref) => { webSearch.refreshCredential(ref) }), + 'ui-plugin-config: credential invalidations', + ) + // The section renders the empty line rather than an empty list when no plugin // contributed a card. The count is read once: the renderer caches a root // entry's inject face per registration, so this reports what was registered diff --git a/packages/client/ui-plugin-config/src/client/web-search-store.ts b/packages/client/ui-plugin-config/src/client/web-search-store.ts index 718aa414bd..5b6907d464 100644 --- a/packages/client/ui-plugin-config/src/client/web-search-store.ts +++ b/packages/client/ui-plugin-config/src/client/web-search-store.ts @@ -143,6 +143,19 @@ export class WebSearchCardController { this.store.set(this.projection()) } + /** + * Re-read after the Host reports a change to the reference this card watches. + * + * A key can be written from somewhere else — the Models page addresses the + * same reference — and the settings section does not change when it is, so + * without this the badge keeps reporting a state the Host already replaced. + * @param ref - the reference the Host reports as changed. + */ + refreshCredential(ref: string): void { + if (ref !== this.credential.ref) return + void this.readCredential() + } + /** * Build the face the card's slot registration injects. * @returns the card's snapshot and its form actions. diff --git a/packages/client/ui-plugin-config/tests/apply.spec.ts b/packages/client/ui-plugin-config/tests/apply.spec.ts index 7c933ca9d2..dad56debe1 100644 --- a/packages/client/ui-plugin-config/tests/apply.spec.ts +++ b/packages/client/ui-plugin-config/tests/apply.spec.ts @@ -17,14 +17,15 @@ async function bench() { await ctx.plugin(SlotsService).await() const locale = new LocaleService(ctx) ctx.provide('locale', locale) + const describeCredentials = vi.fn(() => Promise.resolve({ rpcId: 'c', result: { ok: false, error: {} } })) ctx.provide('connection', { isLoopback: true, api: { settings: { describe: vi.fn(() => Promise.resolve({ rpcId: 's', result: { ok: false, error: {} } })) }, - credentials: { describe: vi.fn(() => Promise.resolve({ rpcId: 'c', result: { ok: false, error: {} } })) }, + credentials: { describe: describeCredentials }, }, } as never) - return { ctx, slots: ctx.get('slots') as SlotsService } + return { ctx, slots: ctx.get('slots') as SlotsService, describeCredentials } } function declareRoot(slots: SlotsService): () => void { @@ -76,6 +77,33 @@ describe('ui-plugin-config apply', () => { } }) + it('re-reads the credential when the Host reports the watched reference changed', async () => { + const { ctx, slots, describeCredentials } = await bench() + declareRoot(slots) + await ctx.plugin({ inject: [...inject], apply }).await() + await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalled() }) + describeCredentials.mockClear() + + // A key written on another surface changes no settings section, so this + // event is the only thing that reaches the card. + ctx.emit('credentials/changed', 'DEEPSEEK_API_KEY') + + await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalledTimes(1) }) + }) + + it('ignores a credential change for a reference no card watches', async () => { + const { ctx, slots, describeCredentials } = await bench() + declareRoot(slots) + await ctx.plugin({ inject: [...inject], apply }).await() + await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalled() }) + describeCredentials.mockClear() + + ctx.emit('credentials/changed', 'SOME_OTHER_KEY') + await Promise.resolve() + + expect(describeCredentials).not.toHaveBeenCalled() + }) + it('registers into a declaration that arrives after apply', async () => { const { ctx, slots } = await bench() await ctx.plugin({ inject: [...inject], apply }).await() diff --git a/packages/client/ui-plugin-config/tests/stores.spec.ts b/packages/client/ui-plugin-config/tests/stores.spec.ts index 60d540f121..78e1ee90c7 100644 --- a/packages/client/ui-plugin-config/tests/stores.spec.ts +++ b/packages/client/ui-plugin-config/tests/stores.spec.ts @@ -436,6 +436,30 @@ describe('WebSearchCardController', () => { expect(credentials.set).not.toHaveBeenCalled() }) + it('re-reads when the Host reports the watched reference changed', async () => { + const host = stubSettingsScope() + const credentials = credentialsApi(false) + const controller = new WebSearchCardController(host.scope, credentials.api) + host.publish({ status: 'ready', writable: true, value: {}, user: {} }) + await vi.waitFor(() => { expect(credentials.describe).toHaveBeenCalled() }) + credentials.describe.mockClear() + + // Another reference is not this card's business. + controller.refreshCredential('OTHER_KEY') + expect(credentials.describe).not.toHaveBeenCalled() + + // A key written on another surface reaches this card only through this signal. + credentials.describe.mockImplementation(() => Promise.resolve({ + rpcId: 'c-1' as never, + result: { ok: true as const, value: { credentials: { DEEPSEEK_API_KEY: { configured: true, writable: true } } } }, + })) + controller.refreshCredential('DEEPSEEK_API_KEY') + + await vi.waitFor(() => { + expect(controller.inject().hooks.webSearchCard.getSnapshot().apiKeyConfigured).toBe(true) + }) + }) + it('addresses the reference the section declares rather than the default', async () => { const host = stubSettingsScope() const credentials = credentialsApi(false)