feat: open recommended plugin details from conversation cards

This commit is contained in:
ZiyaZhang committed 2026-10-19 08:14:08 -07:00
1 parent 8613969e1f
commit 7983ae9d5c
18 files changed
+66 -28

No files matched your search

+4 -1
View File
@@ -63,9 +63,12 @@ describe('official plugin recommendation in a conversation', () => {
await card.getByRole('status').getByText('Dismissed', { exact: true }).waitFor()
acknowledgeReloadConnectionLoss(tripwire, warnings)
expect(await card.count()).toBe(1)
expect(await card.getByRole('button').count()).toBe(0)
expect(await card.getByRole('button').count()).toBe(1)
expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === '@deepseek-ai/dsh-experimental-session-search')?.enabled).toBe(false)
await compareOrRefreshGolden(join(directory, 'dismissed.expected.md'), await captureStableAria(page, '[data-plugin-recommendation]', scaffold.workspaceCwd), scaffold.mode)
await card.getByRole('button', { name: 'View details: Session search', exact: true }).click()
await page.locator('[data-plugin-detail="@deepseek-ai/dsh-experimental-session-search"]').waitFor({ state: 'visible' })
expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === '@deepseek-ai/dsh-experimental-session-search')?.enabled).toBe(false)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
if (scaffold.mode === 'replay') await assertFixtureInventory(directory, [
@@ -9,8 +9,8 @@
en: f0fc11779f589f44
zh: e6f828ecaa30460b
/deepseek-ai-dsh-experimental-client-ui-plugin-recommendations/summary:
en: c7a8e86d94161c24
zh: cbc4f532a57f804e
en: 5c22b43888c4eaa7
zh: 33c341ab607da2f4
/deepseek-ai-dsh-experimental-client-ui-plugin-recommendations/table-of-contents:
en: d152484eb41ac6b4
zh: dbb94479b1334720
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
Users see one plugin recommendation below the completed answer. Enable and Not now are explicit choices. Decisions remain visible after reconnecting.
Users see one plugin recommendation below the completed answer. Clicking its title or description opens that plugin's details in Plugins without enabling it or changing the current Session. Enable and Not now remain separate choices. Decisions and the details link remain available after reconnecting.
## Table of Contents
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
用户在完整回答下方看到一张插件推荐卡片。启用和暂不需要均由用户选择。重新连接后仍能看到之前的选择。
用户在完整回答下方看到一张插件推荐卡片。点击标题或描述可打开插件页中的对应详情,不会启用插件或改变当前 Session。启用和暂不需要仍是独立操作。重新连接后仍能看到之前的选择并打开详情。
## 目录
@@ -34,7 +34,8 @@
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-ui-chat",
"@deepseek-ai/dsh-client-ui-session"
"@deepseek-ai/dsh-client-ui-session",
"@deepseek-ai/dsh-client-ui-plugin-manager"
]
}
},
@@ -55,7 +56,8 @@
"@deepseek-ai/dsh-client-ui-primitives": "workspace:*",
"react": "^18.2.0",
"@deepseek-ai/dsh-experimental-plugin-recommendations": "workspace:*",
"@deepseek-ai/dsh-client-store": "workspace:*"
"@deepseek-ai/dsh-client-store": "workspace:*",
"@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:*"
},
"files": [
"lib/index.js",
@@ -2,6 +2,11 @@
.header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 16px; background: var(--dsw-alias-bg-module-platform); font-size: 12px; color: var(--dsw-alias-label-secondary); }
.badge { font-size: 11px; }
.body { padding: 16px; }
.details { display: block; width: 100%; padding: 0; border: 0; background: none; color: inherit; text-align: start; cursor: pointer; }
.details:focus-visible { outline: 2px solid currentColor; outline-offset: 4px; border-radius: var(--dsw-radius-sm); }
.details:hover .title { text-decoration: underline; }
.detailHint { display: block; margin-top: 8px; font-size: 12px; color: var(--dsw-alias-label-secondary); }
.reason, .description { display: block; }
.title { font-size: 14px; font-weight: 500; overflow-wrap: anywhere; }
.reason { margin: 8px 0 4px; font-size: 13px; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
.description { margin: 0; font-size: 12px; line-height: 1.6; color: var(--dsw-alias-label-secondary); overflow-wrap: anywhere; }
@@ -12,6 +12,7 @@ import css from './Card.module.css'
/** Session-bound callbacks and language, without a service in the component. */
export interface CardInjected {
act: (callId: ToolCallId, action: 'enable' | 'dismiss') => Promise<RecommendationActionResult | undefined>
openDetails: (packageName: string) => void
hooks: { chinese: HostObservable<boolean> }
}
@@ -37,7 +38,7 @@ export function RecommendationTail({ turn, useRecommendations, ...props }: Pick<
* @param props - Session-bound recommendation, actions and locale.
* @returns the card below its completed answer.
*/
export function RecommendationCard({ recommendation, useProjection, useChinese, act, t }: CardProps) {
export function RecommendationCard({ recommendation, useProjection, useChinese, act, openDetails, t }: CardProps) {
const chinese = useChinese(value => value)
const decisions = useProjection('pluginRecommendations')
const outcome = decisions?.decisions[recommendation.callId]
@@ -59,9 +60,13 @@ export function RecommendationCard({ recommendation, useProjection, useChinese,
return <section className={css.card} aria-label={t('heading')} 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}>
<strong className={css.title}>{chinese ? recommendation.titleZh : recommendation.titleEn}</strong>
<p className={css.reason}>{recommendation.reason}</p>
<p className={css.description}>{chinese ? recommendation.descriptionZh : recommendation.descriptionEn}</p>
<button type="button" className={css.details} onClick={() => { openDetails(recommendation.name) }}
aria-label={`${t('details')}: ${chinese ? recommendation.titleZh : recommendation.titleEn}`}>
<strong className={css.title}>{chinese ? recommendation.titleZh : recommendation.titleEn}</strong>
<span className={css.reason}>{recommendation.reason}</span>
<span className={css.description}>{chinese ? recommendation.descriptionZh : recommendation.descriptionEn}</span>
<span className={css.detailHint}>{t('details')} →</span>
</button>
{outcome !== undefined && <p role="status" className={css.status}>{t(outcome)}</p>}
{error !== undefined && <p role="alert" className={css.status}>{t(error)}</p>}
{open && <footer className={css.footer}>
@@ -5,6 +5,7 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client'
import remote from '@deepseek-ai/dsh-experimental-plugin-recommendations/remote'
import type {} from '@deepseek-ai/dsh-experimental-plugin-recommendations/types'
import { RecommendationTail, type RecommendationTailInjected } from './Card.tsx'
@@ -16,7 +17,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
}
/** Registries available before this plugin mounts its optional Remote. */
export const inject = ['slots', 'uiConversation', 'sessions', 'locale', 'remote']
export const inject = ['slots', 'uiConversation', 'sessions', 'locale', 'remote', 'pluginNavigation']
/** Mount the optional Remote and UI for the same plugin lifetime.
* @param ctx - Client plugin context.
@@ -39,6 +40,7 @@ function registerUi(ctx: Context): void {
if (binding === undefined) throw new Error(`plugin-recommendations: unknown session "${sessionId}"`)
const chat = ctx.uiConversation.binding(binding).target('chat')
return {
openDetails: (packageName) => { ctx.pluginNavigation.openBundle(packageName) },
keyedHooks: { recommendations: (turn) => {
const snapshot = chat.getSnapshot()
if (snapshot === undefined) throw new Error('plugin-recommendations: Chat target is unavailable')
@@ -2,6 +2,7 @@
export const zh = {
heading: '推荐插件',
official: '官方',
details: '查看详情',
enable: '启用',
dismiss: '暂不需要',
enabling: '正在处理…',
@@ -28,6 +29,7 @@ export type RecommendationKey = keyof typeof zh
export const en = {
heading: 'Recommended plugin',
official: 'Official',
details: 'View details',
enable: 'Enable',
dismiss: 'Not now',
enabling: 'Working…',
@@ -22,6 +22,8 @@ it('binds actions to the displayed session and removes registrations on unload',
ctx.provide('uiConversation', { events, binding: () => ({ target: () => ({ getSnapshot }) }) })
const binding = vi.fn<() => object | undefined>(() => ({}))
ctx.provide('sessions', { binding })
const openBundle = vi.fn()
ctx.provide('pluginNavigation', { openBundle })
await ctx.plugin(SlotRegistry)
const slots = ctx.slots
slots.register({ name: 'root', children: { 'conversation.chat.turnTail': {
@@ -37,6 +39,8 @@ it('binds actions to the displayed session and removes registrations on unload',
const entry = slots.entries('conversation.chat.turnTail')[0]!
const resolve = entry.inject as NonNullable<typeof entry.inject> & ((sessionId: SessionId) => RecommendationTailInjected)
const bound = resolve(SessionId('session-one'))
bound.openDetails('@deepseek-ai/dsh-experimental-badge-skill-bundle')
expect(openBundle).toHaveBeenCalledWith('@deepseek-ai/dsh-experimental-badge-skill-bundle')
expect(bound.keyedHooks.recommendations('3')).toBe(source)
expect(turnDataSource).toHaveBeenCalledWith(3, 'plugin-recommendation')
getSnapshot.mockReturnValueOnce(undefined)
@@ -20,6 +20,7 @@ function props(outcome?: RecommendationOutcome, chinese = true): Props {
useProjection: () => ({ decisions: outcome === undefined ? {} : { 'rec-1': outcome } }),
useChinese: <Value,>(selector: (value: boolean) => Value) => selector(chinese),
act: vi.fn(async () => ({ status: 'settled', outcome: 'applied' })),
openDetails: vi.fn(),
t: (key: RecommendationKey) => (chinese ? zh : en)[key],
} as Props
}
@@ -29,7 +30,7 @@ it('shows at most one card at the completed turn tail and none when no proposal
const value = props()
const useRecommendations = vi.fn<(turn: string) => readonly Props['recommendation'][] | undefined>(() => undefined)
const tail: Tail = {
...value, useRecommendations: useRecommendations as Tail['useRecommendations'],
...value, useRecommendations,
turn: {
turn: 1, start: undefined, end: undefined, status: 'closed', steps: [],
data: { get: () => undefined, source: () => ({ getSnapshot: () => undefined, subscribe: () => () => {} }) },
@@ -62,15 +63,20 @@ it('allows dismissal and disables both actions until the request settles', async
value.act = vi.fn(() => pending.promise)
render(<RecommendationCard {...value} />)
fireEvent.click(screen.getByRole('button', { name: '暂不需要' }))
expect(screen.getAllByRole('button').every(button => (button as HTMLButtonElement).disabled)).toBe(true)
expect(screen.getByRole('button', { name: '正在处理…' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: '暂不需要' }).hasAttribute('disabled')).toBe(true)
expect(value.act).toHaveBeenCalledWith('rec-1', 'dismiss')
await act(async () => { pending.resolve({ status: 'settled', outcome: 'dismissed' }) })
})
it.each(['applied', 'dismissed', 'restart-required', 'overridden'] as const)('restores %s from the session without action buttons', (outcome) => {
render(<RecommendationCard {...props(outcome)} />)
it.each(['applied', 'dismissed', 'restart-required', 'overridden'] as const)('restores %s and keeps details available without repeating the decision', (outcome) => {
const value = props(outcome)
render(<RecommendationCard {...value} />)
expect(screen.getByRole('status').textContent).toBe(zh[outcome])
expect(screen.queryByRole('button')).toBeNull()
expect(screen.getAllByRole('button')).toHaveLength(1)
fireEvent.click(screen.getByRole('button', { name: '查看详情: 会话搜索' }))
expect(value.openDetails).toHaveBeenCalledWith('official-search')
expect(value.act).not.toHaveBeenCalled()
})
it.each(['failed', 'cancelled'] as const)('keeps a retry available after %s', async (outcome) => {
@@ -99,5 +105,8 @@ it('does not offer a writable card before its projection arrives', () => {
const value = props()
value.useProjection = (() => undefined)
render(<RecommendationCard {...value} />)
expect(screen.getAllByRole('button').every(button => (button as HTMLButtonElement).disabled)).toBe(true)
expect(screen.getByRole('button', { name: '启用' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: '暂不需要' }).hasAttribute('disabled')).toBe(true)
fireEvent.click(screen.getByRole('button', { name: '查看详情: 会话搜索' }))
expect(value.openDetails).toHaveBeenCalledWith('official-search')
})
@@ -46,6 +46,9 @@
},
{
"path": "../../client/store"
},
{
"path": "../../client/ui-plugin-manager/tsconfig.client.json"
}
]
}
@@ -9,8 +9,8 @@
en: a59118891a7040bf
zh: 405bdb33f06112fa
/deepseek-ai-dsh-experimental-plugin-recommendations/summary:
en: fff6c60cd7ba7153
zh: 8c1df3f47c6ae112
en: 26ab7363bb9dcf4a
zh: d5fd77b43c229a9f
/deepseek-ai-dsh-experimental-plugin-recommendations/table-of-contents:
en: d152484eb41ac6b4
zh: 59094206ec49ad20
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
Users can discover a relevant built-in plugin while discussing a concrete need. The Agent answers first, then offers one card at the completed Turn’s end; the user chooses Enable or Not now. Enabling changes the whole profile and persists across restarts. Only an immediately applied change resumes the original request.
Users can discover a relevant built-in plugin while discussing a concrete need. The Agent answers first, then offers one card at the completed Turn’s end; the user can open its plugin details, choose Enable, or choose Not now. Enabling changes the whole profile and persists across restarts. Only an immediately applied change resumes the original request.
## Table of Contents
@@ -9,7 +9,7 @@ kind: "package-bundle"
## 概述
用户表达具体需求时,Agent 可以推荐相关内置插件。Agent 先回答,再在本轮结尾显示一张推荐卡片;用户选择启用或暂不需要。启用会修改整个配置档案,并在重启后保留。只有即时生效的变更才会继续原任务。
用户表达具体需求时,Agent 可以推荐相关内置插件。Agent 先回答,再在本轮结尾显示一张推荐卡片;用户可以打开对应插件详情、启用或暂不需要。启用会修改整个配置档案,并在重启后保留。只有即时生效的变更才会继续原任务。
## 目录
+3
View File
@@ -8382,6 +8382,9 @@ importers:
'@deepseek-ai/dsh-client-ui-conversation':
specifier: workspace:*
version: link:../../client/ui-conversation
'@deepseek-ai/dsh-client-ui-plugin-manager':
specifier: workspace:*
version: link:../../client/ui-plugin-manager
'@deepseek-ai/dsh-client-ui-primitives':
specifier: workspace:*
version: link:../../client/ui-primitives
@@ -1,6 +1,6 @@
- region "Recommended plugin":
- text: Recommended plugin Official
- strong: Session search
- paragraph: Search your saved conversations from this workspace.
- paragraph: Give Agents tools to search and read earlier sessions from the current workspace.
- 'button "View details: Session search"':
- strong: Session search
- text: Search your saved conversations from this workspace. Give Agents tools to search and read earlier sessions from the current workspace. View details →
- status: Dismissed
@@ -1,8 +1,8 @@
- region "Recommended plugin":
- text: Recommended plugin Official
- strong: Session search
- paragraph: Search your saved conversations from this workspace.
- paragraph: Give Agents tools to search and read earlier sessions from the current workspace.
- 'button "View details: Session search"':
- strong: Session search
- text: Search your saved conversations from this workspace. Give Agents tools to search and read earlier sessions from the current workspace. View details →
- text: Available in all conversations when enabled
- button "Not now"
- button "Enable"