fix(experimental): keep recommended plugin titles in place while they load

Catalog text comes from an asynchronous Host lookup, so entering a Session
showed the package name and then moved the card when the title, description
and actions arrived. The browser contribution now keeps returned catalog text
by plugin name in a store owned by its UI lifetime, so a revisited card
renders it at once while its own lookup refreshes the selection, refusal or
lost reply. Until a plugin's first lookup returns, placeholder lines and the
disabled actions hold the title, description and footer in place.
This commit is contained in:
ZiyaZhang committed 2026-10-22 03:19:20 -07:00
1 parent 51000d47c5
commit f997618f51
8 files changed
+120 -33

No files matched your search

@@ -18,8 +18,8 @@
en: 62e446e9cb825f36
zh: 620506f3986eb9a9
/deepseek-ai-dsh-experimental-client-ui-plugin-recommendations/understand-the-implementation:
en: 5cc1ecf907eabad8
zh: 3ad6d1afc6944a6b
en: d53f149533596c28
zh: 77e539467885f600
/deepseek-ai-dsh-experimental-client-ui-plugin-recommendations/further-exploration:
en: 857c404e794d16f9
zh: 9e907212efe09dd9
@@ -30,8 +30,8 @@
en: 4faf0dd1beff482e
zh: 77ea65782433f221
/deepseek-ai-dsh-experimental-client-ui-plugin-recommendations/known-limitations-and-deferred-work:
en: 49c565c266959430
zh: 0b380afd42533f2d
en: a1dd9c7a4fbd5971
zh: 8909053269549211
/deepseek-ai-dsh-experimental-client-ui-plugin-recommendations/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: 8ec3e73528ddb1a6
@@ -35,7 +35,7 @@ Enable the [Plugin recommendations bundle](../plugin-recommendations/README.md).
<details>
<summary>Implementation internals — click to expand</summary>
The Turn tail reads the proposals of the `pluginRecommendations` Session projection and shows the one admitted in its Turn once that Turn has ended, for any end reason; the projection admits at most one proposal per Turn, and the chat hides the fence itself. A proposal the projection marks never offered renders nothing. The card asks the Host for the plugin's current catalog text and selection; a name outside the eligible catalog shows no actions. Session projection decisions drive the card after reload. The optional Remote and all UI registrations are removed together on unload.
The Turn tail reads the proposals of the `pluginRecommendations` Session projection and shows the one admitted in its Turn once that Turn has ended, for any end reason; the projection admits at most one proposal per Turn, and the chat hides the fence itself. A proposal the projection marks never offered renders nothing. The card asks the Host for the plugin's current catalog text and selection; a name outside the eligible catalog shows no actions. The browser contribution keeps returned catalog text by plugin name for its lifetime, so a revisited card renders it at once while its own lookup refreshes the selection. Until a plugin's first lookup returns, placeholder lines and the disabled actions hold the title, description and footer in place. Session projection decisions drive the card after reload. The optional Remote and all UI registrations are removed together on unload.
Cards use the application font, the deliverables card border and background, a distinct title and compact body scale, and a separate action footer. The built entry supplies generated Remote descriptors to the source-tested mount lifecycle.
@@ -67,6 +67,7 @@ The card adds no model context. Its Enable action can change subsequent tool dec
<a id="known-limitations-and-deferred-work"></a>
- Only the first proposal in a Turn appears.
- Before a plugin's first lookup returns, the card reserves one description line; a longer description still extends the card when it arrives.
- Historical decisions do not track later changes made from the Plugins page.
<a id="dev-note"></a>
@@ -35,7 +35,7 @@ kind: "package-reference"
<details>
<summary>实现细节 — 点击展开</summary>
回合末尾插槽读取 `pluginRecommendations` Session 投影中的提案,在回合结束后(无论以何种原因结束)显示该回合接纳的那一条;投影每个回合最多接纳一条提案,聊天界面隐藏围栏本身。投影标为未展示的提案不渲染。卡片向 Host 读取插件当前的目录文本和启用状态;不在可推荐目录里的名字不显示操作按钮。Session 投影恢复刷新后的选择状态。卸载时同时移除可选 Remote 和所有界面注册。
回合末尾插槽读取 `pluginRecommendations` Session 投影中的提案,在回合结束后(无论以何种原因结束)显示该回合接纳的那一条;投影每个回合最多接纳一条提案,聊天界面隐藏围栏本身。投影标为未展示的提案不渲染。卡片向 Host 读取插件当前的目录文本和启用状态;不在可推荐目录里的名字不显示操作按钮。浏览器端在插件生命周期内按插件名记住读到的目录文本,再次进入会话时卡片立即显示,同时卡片自己的查询会刷新启用状态。某个插件的第一次查询返回前,卡片用占位行和禁用的按钮占住标题、描述和操作区的位置。Session 投影恢复刷新后的选择状态。卸载时同时移除可选 Remote 和所有界面注册。
卡片沿用应用字体和交付物卡片的边框与背景,通过标题、正文和独立操作区区分信息层级。构建入口将生成的 Remote 描述传给可直接从源码测试的挂载流程。
@@ -67,6 +67,7 @@ kind: "package-reference"
<a id="known-limitations-and-deferred-work"></a>
- 每个回合只显示第一条推荐。
- 插件第一次查询返回前,卡片只预留一行描述;更长的描述返回后卡片仍会变高。
- 历史选择不会跟踪之后在插件页面进行的修改。
<a id="dev-note"></a>
@@ -9,6 +9,7 @@
.title { display: block; font-size: 16px; font-weight: 500; line-height: 1.5; overflow-wrap: anywhere; }
.reason { margin: 8px 0; font-size: 13px; line-height: 1.7; white-space: pre-wrap; overflow-wrap: anywhere; }
.description { margin: 0; font-size: 13px; line-height: 1.7; color: var(--dsw-alias-label-secondary); overflow-wrap: anywhere; }
.placeholder { display: inline-block; width: 50%; height: 0.8em; vertical-align: middle; border-radius: var(--dsw-radius-xs); background: var(--dsw-alias-bg-skeleton); }
.footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 16px; padding-top: 16px; border-top: 0.5px solid var(--dsw-alias-border-l1); flex-wrap: wrap; }
.scope { font-size: 12px; line-height: 1.5; color: var(--dsw-alias-label-secondary); }
.actions { display: flex; align-items: center; gap: 8px; margin-left: auto; }
@@ -11,13 +11,18 @@ import type {} from '@deepseek-ai/dsh-client-ui-chat/client'
import type { RecommendationKey } from './locales.ts'
import css from './Card.module.css'
/** Session-bound callbacks and language, without a service in the component. */
/** Catalog text the Host returned for an available plugin. */
export type CatalogDetails = Extract<RecommendationDetails, { readonly status: 'available' }>
/** Session-bound callbacks, language and the plugin-lifetime catalog text, without a service in the component. */
export interface CardInjected {
act: (callId: ToolCallId, action: 'enable' | 'dismiss') => Promise<RecommendationActionResult | undefined>
describe: (callId: ToolCallId) => Promise<RecommendationDetails | undefined>
/** Keep a plugin's catalog text so later cards of the same plugin render it at once. */
remember: (name: string, details: CatalogDetails) => void
openDetails: (packageName: string) => void
resolveText: (text: LocalizedText) => string
hooks: { locale: HostObservable<string> }
hooks: { locale: HostObservable<string>; catalog: HostObservable<Readonly<Record<string, CatalogDetails>>> }
}
type CardProps = { recommendation: PluginRecommendation } & Pick<PropsRuntime<'conversation.chat.turnTail'>, 'useProjection'>
@@ -49,28 +54,39 @@ export function RecommendationTail({ turn, ...props }: Pick<PropsRuntime<'conver
/* jscpd:ignore-end */
/** Display one recommendation, its current catalog text, and the persisted outcome of the user's choice.
* Until the first catalog lookup of a plugin returns, placeholder lines and the disabled actions hold the final layout.
* @param props - Session-bound recommendation, actions and locale.
* @returns the card below its completed answer.
*/
export function RecommendationCard({ recommendation, useProjection, useLocale, act, describe, openDetails, resolveText, t }: CardProps) {
export function RecommendationCard({
recommendation, useProjection, useLocale, useCatalog, act, describe, remember, openDetails, resolveText, t,
}: CardProps) {
useLocale(value => value)
const view = useProjection('pluginRecommendations')
const state = view?.decisions[recommendation.callId]
// Undefined while loading; null when the Host could not be reached.
const [details, setDetails] = useState<RecommendationDetails | null>()
const cached = useCatalog(catalog => Object.hasOwn(catalog, recommendation.name) ? catalog[recommendation.name] : undefined)
// This card's latest lookup: undefined while loading, null when the Host could not be reached.
const [lookup, setLookup] = useState<RecommendationDetails | null>()
const [pending, setPending] = useState(false)
const [error, setError] = useState<RecommendationKey | undefined>()
useEffect(() => {
let current = true
describe(recommendation.callId).then(
(value) => { if (current) setDetails(value ?? null) },
() => { if (current) setDetails(null) },
(value) => {
if (!current) return
if (value?.status === 'available') remember(recommendation.name, value)
setLookup(value ?? null)
},
() => { if (current) setLookup(null) },
)
return () => { current = false }
}, [describe, recommendation.callId, state])
}, [describe, remember, recommendation.callId, recommendation.name, state])
// Remembered text shows at once; this card's own refusal or lost reply still decides what it offers.
const details = lookup === null || lookup?.status === 'refused' ? lookup : cached ?? lookup
const loading = details === undefined
const available = details?.status === 'available' ? details : undefined
const settled = state ?? (available?.enabled ? 'already-enabled' : undefined)
const open = available !== undefined && !view?.invalid && (settled === undefined || settled === 'failed' || settled === 'cancelled')
const open = (available !== undefined || loading) && !view?.invalid && (settled === undefined || settled === 'failed' || settled === 'cancelled')
const run = async (action: 'enable' | 'dismiss') => {
setPending(true)
setError(undefined)
@@ -85,14 +101,16 @@ export function RecommendationCard({ recommendation, useProjection, useLocale, a
} finally { setPending(false) }
}
const title = available === undefined ? recommendation.name : resolveText(available.title)
return <section className={css.card} aria-label={t('heading')} data-plugin-recommendation={recommendation.callId}>
return <section className={css.card} aria-label={t('heading')} aria-busy={loading || undefined} data-plugin-recommendation={recommendation.callId}>
<header className={css.header}><span>{t('heading')}</span><span className={css.badge}>{t('official')}</span></header>
<div className={css.body}>
<button type="button" className={css.details} onClick={() => { openDetails(recommendation.name) }}
aria-label={t('detailsFor').replace('{title}', title)}>
<strong className={css.title}>{title}</strong>
<strong className={css.title}>{loading ? <span className={css.placeholder} /> : title}</strong>
<span className={css.reason}>{recommendation.reason}</span>
{available !== undefined && <span className={css.description}>{resolveText(available.description)}</span>}
{(loading || available !== undefined) && <span className={css.description}>
{available === undefined ? <span className={css.placeholder} /> : resolveText(available.description)}
</span>}
</button>
{settled !== undefined && <p role="status" className={css.status}>{t(settled)}</p>}
{view?.invalid ? <p role="alert" className={css.status}>{t('invalid-history')}</p>
@@ -102,8 +120,8 @@ export function RecommendationCard({ recommendation, useProjection, useLocale, a
{open && <footer className={css.footer}>
<span className={css.scope}>{t('scope')}</span>
<div className={css.actions}>
<Button variant="ghost" size="sm" disabled={pending || view === undefined} onClick={() => { void run('dismiss') }}>{t('dismiss')}</Button>
<Button variant="primary" size="sm" disabled={pending || view === undefined} onClick={() => { void run('enable') }}>
<Button variant="ghost" size="sm" disabled={pending || loading || view === undefined} onClick={() => { void run('dismiss') }}>{t('dismiss')}</Button>
<Button variant="primary" size="sm" disabled={pending || loading || view === undefined} onClick={() => { void run('enable') }}>
{t(pending ? 'enabling' : settled === undefined ? 'enable' : 'retry')}
</Button>
</div>
@@ -1,5 +1,6 @@
/** Register completed-answer recommendations and their localized actions. */
import type { Context } from '@deepseek-ai/cordis'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type {} from '@deepseek-ai/dsh-client-locale/client'
@@ -8,7 +9,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client'
import type {} from '@deepseek-ai/dsh-experimental-plugin-recommendations/remote'
import type { TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol'
import type {} from '@deepseek-ai/dsh-experimental-plugin-recommendations/types'
import { RecommendationTail, type CardInjected } from './Card.tsx'
import { RecommendationTail, type CardInjected, type CatalogDetails } from './Card.tsx'
import { en, ja, zh, type RecommendationKey } from './locales.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
@@ -34,6 +35,8 @@ export async function mountRecommendations(ctx: Context, contribution: TypertRem
}
function registerUi(ctx: Context): void {
// Catalog text by plugin name for this plugin lifetime: a revisited card renders it before its own lookup returns.
const catalog = createSnapshotStore<Readonly<Record<string, CatalogDetails>>>({})
ctx.effect(() => ctx.locale.register('pluginRecommendations', { en, zh, ja }), 'plugin-recommendations: locale')
ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({
name: 'conversation.chat.turnTail', id: 'plugin-recommendation', locale: 'pluginRecommendations',
@@ -42,7 +45,8 @@ function registerUi(ctx: Context): void {
hooks: { locale: {
getSnapshot: () => ctx.locale.getSnapshot().active,
subscribe: listener => ctx.locale.subscribe(listener),
} },
}, catalog },
remember: (name, details) => { catalog.set({ ...catalog.getSnapshot(), [name]: details }) },
resolveText: text => ctx.locale.resolveText(text),
describe: async (callId) => {
const result = await ctx.remote.pluginRecommendations.describe(sessionId, callId)
@@ -1,4 +1,4 @@
/** Real registries validate session binding and HMR removal. */
/** Real registries validate session binding, the plugin-lifetime catalog and HMR removal. */
import { Context } from '@deepseek-ai/cordis'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
@@ -46,12 +46,19 @@ it('binds actions to the displayed session and removes registrations on unload',
expect(act).toHaveBeenCalledWith('session-one', 'proposal', 'enable')
act.mockResolvedValueOnce({ ok: false })
expect(await bound.act(ToolCallId('proposal'), 'dismiss')).toBeUndefined()
const details = { status: 'available', title: 'Search', description: 'Find past sessions', enabled: false } as const
bound.remember('official-search', details)
expect(resolve(SessionId('session-two')).hooks.catalog.getSnapshot()).toEqual({ 'official-search': details })
await fiber.dispose()
expect(unmount).toHaveBeenCalledOnce()
expect(slots.entries('conversation.chat.turnTail')).toHaveLength(0)
const remounted = await ctx.plugin({ inject, apply })
const fresh = slots.entries('conversation.chat.turnTail')[0]!.inject as NonNullable<typeof entry.inject> & ((sessionId: SessionId) => CardInjected)
expect(fresh(SessionId('session-one')).hooks.catalog.getSnapshot()).toEqual({})
await remounted.dispose()
vi.spyOn(locale, 'register').mockImplementation(() => { throw new Error('registration failed') })
const failed = ctx.plugin({ inject, apply })
await expect(failed.await()).rejects.toThrow('registration failed')
await failed.dispose()
expect(unmount).toHaveBeenCalledTimes(2)
expect(unmount).toHaveBeenCalledTimes(3)
})
@@ -1,12 +1,13 @@
// @vitest-environment jsdom
import type { ComponentProps } from 'react'
import { useSyncExternalStore, type ComponentProps } from 'react'
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import type { RecommendationCardState, RecommendationDetails, RecommendationView } from '@deepseek-ai/dsh-experimental-plugin-recommendations/types'
import type { LocalizedText } from '@deepseek-ai/dsh-package-manifest'
import { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
import { SessionSeq } from '@deepseek-ai/dsh-session/types'
import { RecommendationCard, RecommendationTail } from '../src/client/Card.tsx'
import { createSnapshotStore, type SnapshotSelectorHook } from '@deepseek-ai/dsh-client-store'
import { RecommendationCard, RecommendationTail, type CatalogDetails } from '../src/client/Card.tsx'
import { en, ja, zh, type RecommendationKey } from '../src/client/locales.ts'
afterEach(cleanup)
@@ -17,11 +18,22 @@ const available: RecommendationDetails = {
const recommendation = { callId: ToolCallId('block:m1'), name: 'official-search', reason: '帮你找到上周讨论的方案' }
function props(state?: RecommendationCardState, details: RecommendationDetails = available, view?: Partial<RecommendationView>): Props {
/** The plugin-lifetime catalog mount.ts creates, with the selector hook the slot binds to it. */
function catalog() {
const store = createSnapshotStore<Readonly<Record<string, CatalogDetails>>>({})
const useCatalog: SnapshotSelectorHook<Readonly<Record<string, CatalogDetails>>> = selector =>
useSyncExternalStore(listener => store.subscribe(listener), () => selector(store.getSnapshot()))
const remember = vi.fn((name: string, details: CatalogDetails) => { store.set({ ...store.getSnapshot(), [name]: details }) })
return { store, useCatalog, remember }
}
function props(state?: RecommendationCardState, details: RecommendationDetails = available, view?: Partial<RecommendationView>,
{ useCatalog, remember } = catalog()): Props {
return {
recommendation,
useProjection: () => ({ decisions: state === undefined ? {} : { 'block:m1': state }, proposals: { 'block:m1': { ...recommendation, turn: 1 } }, ...view }),
useLocale: <Value,>(selector: (value: string) => Value) => selector('zh-CN'),
useCatalog, remember,
act: vi.fn(async () => ({ status: 'settled', outcome: 'applied' })),
describe: vi.fn(async () => details),
openDetails: vi.fn(),
@@ -147,20 +159,37 @@ it('reports an unreachable Host and an unreadable history without actions', asyn
expect(screen.queryByRole('button', { name: '启用' })).toBeNull()
})
it('keeps actions disabled until the card state loads and shows the name while catalog text loads', async () => {
it('keeps actions disabled until the card state loads', async () => {
const value = props()
value.useProjection = () => undefined
await shown(value)
expect(screen.getByRole('button', { name: '启用' }).hasAttribute('disabled')).toBe(true)
cleanup()
})
it('holds the title, description and action lines while the first catalog lookup is pending', async () => {
const loading = props()
const describe = Promise.withResolvers<RecommendationDetails | undefined>()
loading.describe = vi.fn(() => describe.promise)
const { unmount } = render(<RecommendationCard {...loading} />)
expect(screen.getByText('official-search')).toBeTruthy()
expect(screen.queryByRole('button', { name: '启用' })).toBeNull()
unmount()
const { container } = render(<RecommendationCard {...loading} />)
const region = screen.getByRole('region', { name: '推荐插件' })
expect(region.getAttribute('aria-busy')).toBe('true')
expect(screen.queryByText('official-search')).toBeNull()
expect(container.querySelectorAll('strong > span, button span > span')).toHaveLength(2)
expect(screen.getByText('帮你找到上周讨论的方案')).toBeTruthy()
expect(screen.getByText('启用后可在所有会话中使用')).toBeTruthy()
for (const name of ['暂不需要', '启用']) expect(screen.getByRole('button', { name }).hasAttribute('disabled')).toBe(true)
await act(async () => { describe.resolve(available) })
expect(region.hasAttribute('aria-busy')).toBe(false)
expect(within(screen.getByRole('button', { name: '查看详情:会话搜索' })).getByText('检索过去的会话')).toBeTruthy()
expect(screen.getByRole('button', { name: '启用' }).hasAttribute('disabled')).toBe(false)
expect(loading.remember).toHaveBeenCalledExactlyOnceWith('official-search', available)
cleanup()
const pending = props()
const late = Promise.withResolvers<RecommendationDetails | undefined>()
pending.describe = vi.fn(() => late.promise)
render(<RecommendationCard {...pending} />).unmount()
await act(async () => { late.resolve(available) })
expect(pending.remember).not.toHaveBeenCalled()
const failing = props()
const rejected = Promise.withResolvers<RecommendationDetails | undefined>()
failing.describe = vi.fn(() => rejected.promise)
@@ -168,6 +197,32 @@ it('keeps actions disabled until the card state loads and shows the name while c
await act(async () => { rejected.reject(new Error('late')) })
})
it('renders a remembered plugin at once on a later card while refusals and lost replies still decide its actions', async () => {
const shared = catalog()
await shown(props(undefined, available, undefined, shared))
expect(shared.store.getSnapshot()).toEqual({ 'official-search': available })
cleanup()
const revisit = props(undefined, available, undefined, shared)
const lookup = Promise.withResolvers<RecommendationDetails | undefined>()
revisit.describe = vi.fn(() => lookup.promise)
render(<RecommendationCard {...revisit} />)
expect(screen.getByRole('region').hasAttribute('aria-busy')).toBe(false)
expect(within(screen.getByRole('button', { name: '查看详情:会话搜索' })).getByText('检索过去的会话')).toBeTruthy()
expect(screen.getByRole('button', { name: '启用' }).hasAttribute('disabled')).toBe(false)
await act(async () => { lookup.resolve({ ...available, enabled: true }) })
expect(screen.getByRole('status').textContent).toBe(zh['already-enabled'])
cleanup()
await shown(props(undefined, { status: 'refused', reason: 'unavailable' }, undefined, shared))
expect(screen.getByRole('alert').textContent).toBe(zh.unavailable)
expect(screen.queryByRole('button', { name: '启用' })).toBeNull()
cleanup()
const lost = props(undefined, available, undefined, shared)
lost.describe = vi.fn(async () => undefined)
await shown(lost)
expect(screen.getByRole('alert').textContent).toBe(zh.transport)
expect(screen.queryByRole('button', { name: '启用' })).toBeNull()
})
it.each([
[undefined, zh.transport],
[{ status: 'refused', reason: 'access-denied' }, zh['access-denied']],