feat: open recommended plugin details from conversation cards
This commit is contained in:
1 parent
8613969e1f
commit
7983ae9d5c
18 files changed
+66
-28
No files matched your search
@@ -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…',
|
||||
|
||||
+4
@@ -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 先回答,再在本轮结尾显示一张推荐卡片;用户可以打开对应插件详情、启用或暂不需要。启用会修改整个配置档案,并在重启后保留。只有即时生效的变更才会继续原任务。
|
||||
|
||||
## 目录
|
||||
|
||||
|
||||
Generated
+3
@@ -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"
|
||||
Reference in new issue
Block a user