refactor(web): limit plugin UX changes to add and create flows
This commit is contained in:
1 parent
6200ffe8c7
commit
7d88fc9b2b
40 files changed
+163
-995
No files matched your search
@@ -269,7 +269,7 @@ describe('web e2e: agent-preset selection', () => {
|
||||
|
||||
it('names every preset and what it is for', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-menu'))
|
||||
await page.getByRole('button', { name: 'Standard mode', exact: true }).click()
|
||||
await page.getByRole('button', { name: 'Standard mode' }).click()
|
||||
const menu = page.getByRole('menu')
|
||||
await menu.waitFor({ timeout: 10_000 })
|
||||
|
||||
@@ -285,7 +285,7 @@ describe('web e2e: agent-preset selection', () => {
|
||||
|
||||
it('applies the staged pick to the blank session, and the host honors it', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-stage'))
|
||||
await page.getByRole('button', { name: 'Standard mode', exact: true }).click()
|
||||
await page.getByRole('button', { name: 'Standard mode' }).click()
|
||||
await page.getByRole('menuitem', { name: /Minimal mode/ }).click()
|
||||
|
||||
// The chip stages; the blank session the workspace connect produced is
|
||||
@@ -296,7 +296,7 @@ describe('web e2e: agent-preset selection', () => {
|
||||
})
|
||||
|
||||
it('omits eagerly failed presets from selection and retains their diagnostics', async () => {
|
||||
await page.getByRole('button', { name: 'Minimal mode', exact: true }).click()
|
||||
await page.getByRole('button', { name: 'Minimal mode' }).click()
|
||||
await page.getByRole('menu').waitFor()
|
||||
expect(await page.getByRole('menuitem', { name: /Refusing mode/ }).count()).toBe(0)
|
||||
await page.keyboard.press('Escape')
|
||||
@@ -329,7 +329,7 @@ describe('web e2e: agent-preset selection', () => {
|
||||
// against its list row, so a row that never reprojected the first switch
|
||||
// answers "already standard" and sends nothing — and restores the catalog
|
||||
// instead of leaving the session reading the narrower composition.
|
||||
await page.getByRole('button', { name: 'Minimal mode', exact: true }).click()
|
||||
await page.getByRole('button', { name: 'Minimal mode' }).click()
|
||||
await page.getByRole('menuitem', { name: /^Standard mode/ }).first().click()
|
||||
await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('standard')
|
||||
|
||||
@@ -373,7 +373,7 @@ describe('web e2e: agent-preset selection', () => {
|
||||
await reopened.getByRole('button', { name: 'New task default: Minimal mode' }).waitFor({ timeout: 10_000 })
|
||||
await reopened.getByRole('button', { name: 'Close' }).last().click()
|
||||
await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('minimal')
|
||||
await page.getByRole('button', { name: 'Minimal mode', exact: true }).waitFor({ timeout: 10_000 })
|
||||
await page.getByRole('button', { name: 'Minimal mode' }).waitFor({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
it('labels a resumed session with the preset it was created under', async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
- navigation "Session hierarchy": Seeded turn
|
||||
- button "1 subagent"
|
||||
- 'button "Mode details: Minimal mode"': Minimal mode
|
||||
- text: Minimal mode
|
||||
- button "More actions"
|
||||
- button "Open right sidebar"
|
||||
@@ -1,3 +1,2 @@
|
||||
- button "Choose workspace": workspace
|
||||
- button "Standard mode"
|
||||
- 'button "Mode details: Standard mode"'
|
||||
@@ -3,8 +3,8 @@
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/slots.md
|
||||
/web-client-slots:
|
||||
en: 593f9ed34e925132
|
||||
zh: f82611667c858768
|
||||
en: 8d33b164230344fa
|
||||
zh: 13a930ab96280744
|
||||
/web-client-slots/declaration-and-lifecycle:
|
||||
en: f2d2c0d3b8f14ec7
|
||||
zh: 441ed13ff3f81d2a
|
||||
|
||||
@@ -8,7 +8,7 @@ This page documents slot ownership, component inputs, extension APIs, and the sh
|
||||
|
||||
`plugins.bundle.config` supplies bundle detail configuration, keyed by npm package name. `plugins.bundle.activation` renders optional guidance after user-requested enablement, with owner callbacks to dismiss or open that bundle’s details. `conversation.input.activity` supplies one action between the model selector and Send, with toolbar expansion released on unmount.
|
||||
|
||||
The plugin catalog declares `plugins.add.actions`, a root-scoped list for `MenuItemButton` rows after installation in the Add plugin menu. Its owner supplies `onDismiss()` to close the menu before starting an action. It also declares `plugins.bundle.usage`, a root-scoped keyed slot for usage guidance before component details. The latter dispatches the npm package name and supplies `{ pkg: PluginPackageRef }`; its occupant decides which actions apply to the bundle's installed and enabled state. Both follow the owning Plugins page declaration lifetime.
|
||||
The plugin catalog declares `plugins.add.actions`, a root-scoped list for `MenuItemButton` rows after installation in the Add plugin menu. Its owner supplies `onDismiss()` to close the menu before starting an action. The slot follows the owning Plugins page declaration lifetime.
|
||||
|
||||
## Declaration and lifecycle
|
||||
|
||||
@@ -137,8 +137,6 @@ root
|
||||
├─ main
|
||||
│ ├─ plugins.add.actions
|
||||
│ ├─ plugins.item
|
||||
│ ├─ plugins.bundle.activation
|
||||
│ ├─ plugins.bundle.usage
|
||||
│ ├─ plugins.bundle.config
|
||||
│ ├─ plugins.row.config
|
||||
│ ├─ plugins.detail.actions
|
||||
|
||||
@@ -8,7 +8,7 @@ Slots 是 Web Client 的类型化 React 组合系统。[`dsh-client-ui-slots`](.
|
||||
|
||||
`plugins.bundle.config` 以 npm 包名为 key,提供 Bundle 详情配置。`plugins.bundle.activation` 在用户显式启用后渲染可选引导,由拥有者提供关闭引导和打开该 Bundle 详情的回调。`conversation.input.activity` 提供模型选择器与发送按钮之间的单个活动控件,可展开工具栏并在卸载时释放展开状态。
|
||||
|
||||
插件目录声明 `plugins.add.actions`,它是 root 作用域的 list,在添加插件菜单的安装项之后提供 `MenuItemButton` 行。owner 提供 `onDismiss()`,用于开始动作前关闭菜单。同时声明 `plugins.bundle.usage`,它是 root 作用域的 keyed slot,在组件详情之前提供使用指引。后者以 npm 包名分派,并传入 `{ pkg: PluginPackageRef }`;占用者根据组合包的安装和启用状态决定适用动作。两者均遵循所属插件页面声明的生命周期。
|
||||
插件目录声明 `plugins.add.actions`,它是 root 作用域的 list,在添加插件菜单的安装项之后提供 `MenuItemButton` 行。owner 提供 `onDismiss()`,用于开始动作前关闭菜单。该 slot 遵循所属插件页面声明的生命周期。
|
||||
|
||||
## 声明与生命周期
|
||||
|
||||
@@ -137,8 +137,6 @@ root
|
||||
├─ main
|
||||
│ ├─ plugins.add.actions
|
||||
│ ├─ plugins.item
|
||||
│ ├─ plugins.bundle.activation
|
||||
│ ├─ plugins.bundle.usage
|
||||
│ ├─ plugins.bundle.config
|
||||
│ ├─ plugins.row.config
|
||||
│ ├─ plugins.detail.actions
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: b3ad7867f23dad1c
|
||||
/deepseek-ai-dsh-client-ui-agent-preset/use-this-package:
|
||||
en: d778cf8ed737f5f8
|
||||
zh: 3cf1a1d9f8c14d38
|
||||
en: 3f1c4b7b23f9b379
|
||||
zh: b87af50275888cb1
|
||||
/deepseek-ai-dsh-client-ui-agent-preset/understand-the-implementation:
|
||||
en: 7813f0063c15e241
|
||||
zh: 6eabc242edfc45ec
|
||||
|
||||
@@ -31,7 +31,7 @@ Coding Tools in General Settings control ordinary preset selection: with them of
|
||||
|
||||
Known shipped presets offer mode details and usage examples in a read-only dialog. Its tabs preserve each page's scroll position; closing returns focus to the opening action. Help does not change the new-task default. The default badge replaces the card's group badge, and the preset id appears beside the title. Guide copy and examples belong to this package.
|
||||
|
||||
The new-task picker and session header also open the same read-only help. The Plugins page’s “Add plugin” menu offers “Let the agent create a plugin”, including when Coding Tools are off. Choosing it closes the menu and stages Creator for one task without submitting a message, replacing a draft, or changing global preferences. With Coding Tools off, the new-task page shows only a read-only Creator label and help; it offers no preset switching. The next ordinary task uses the saved default. The menu item stays in place while the roster loads or refreshes, and explains why it is disabled when Creator mode is unavailable. The optional slot contribution disappears with its owner.
|
||||
The Plugins page’s “Add plugin” menu offers “Let the agent create a plugin”, including when Coding Tools are off. Choosing it closes the menu and stages Creator for one task without submitting a message, replacing a draft, or changing global preferences. With Coding Tools off, the new-task page shows a read-only Creator label that opens its help; it offers no preset switching. The next ordinary task uses the saved default. The menu item stays in place while the roster loads or refreshes, and explains why it is disabled when Creator mode is unavailable. The optional slot contribution disappears with its owner.
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## Understand the implementation
|
||||
|
||||
@@ -31,7 +31,7 @@ kind: "package-reference"
|
||||
|
||||
已知的内置预设提供只读的模式说明与使用示例对话框。各页签保留各自的滚动位置;关闭后焦点回到打开它的操作。帮助不会改变新任务默认值。默认徽标取代卡片的分组徽标,预设 id 显示在标题旁。指南文案与示例归本包所有。
|
||||
|
||||
新任务选择器和任务标题也可打开同一份只读说明。插件页的「添加插件」菜单提供「让 Agent 创建插件」,代码工作工具关闭时也可使用。选择后关闭菜单,为一次任务暂存创造模式,不发送消息、不替换草稿,也不修改全局偏好。代码工作工具关闭时,新任务页仅显示只读的创造模式标签和说明,不提供预设切换;之后的普通任务继续使用已保存的默认值。预设列表加载或刷新时菜单项保持原位;创造模式不可用时禁用,并解释原因。菜单项由可选插槽贡献,随所属插件卸载而移除。
|
||||
插件页的「添加插件」菜单提供「让 Agent 创建插件」,代码工作工具关闭时也可使用。选择后关闭菜单,为一次任务暂存创造模式,不发送消息、不替换草稿,也不修改全局偏好。代码工作工具关闭时,新任务页显示只读的创造模式标签,点击可打开说明,不提供预设切换;之后的普通任务继续使用已保存的默认值。预设列表加载或刷新时菜单项保持原位;创造模式不可用时禁用,并解释原因。菜单项由可选插槽贡献,随所属插件卸载而移除。
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## 理解实现
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Session-header preset details never change the task's selected mode. */
|
||||
/* Session-header agent-preset label: static chrome, never a control. */
|
||||
|
||||
.label {
|
||||
display: inline-flex;
|
||||
@@ -7,7 +7,6 @@
|
||||
max-width: 180px;
|
||||
padding: 0 2px 0 0;
|
||||
height: 22px;
|
||||
border: none;
|
||||
border-radius: var(--dsw-radius-xs);
|
||||
background: var(--dsw-alias-fill-tsp-secondary);
|
||||
font-size: 12px;
|
||||
@@ -18,33 +17,14 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.details {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.details:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.details:focus-visible {
|
||||
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* First sacrifice when the title row (the enclosing inline-size container)
|
||||
runs out of width: mode help yields before task actions lose their labels. */
|
||||
runs out of width: the preset is passive chrome, so it yields before any
|
||||
interactive header action loses its label. */
|
||||
@container (max-width: 540px) {
|
||||
.label {
|
||||
display: none;
|
||||
|
||||
@@ -3,21 +3,20 @@
|
||||
*
|
||||
* Read-only by construction: a session's composition is fixed once its
|
||||
* conversation starts, and a header is only worth reading after that. Offering
|
||||
* a selection control here would promise a switch the host refuses. Its
|
||||
* read-only details explain the mode, and the choice itself lives on the
|
||||
* a control here would promise a switch the host refuses; naming what the
|
||||
* session runs is the honest affordance, and the choice itself lives on the
|
||||
* new-session screen ({@link AgentPresetSeat}).
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { IconAgentPresetOutlineRegular, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconAgentPresetOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
// Type-only: pulls the ui-conversation SlotMap merge (the header actions).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {} from '@deepseek-ai/dsh-agent-preset-registry/types'
|
||||
import type { AgentPresetSettingsState } from './settings-store.ts'
|
||||
import { isBuiltInPreset, presetDisplayText } from './locales.ts'
|
||||
import { PresetGuideDialog, presetGuide } from './PresetGuideDialog.tsx'
|
||||
import { presetDisplayText } from './locales.ts'
|
||||
import css from './AgentPresetLabel.module.css'
|
||||
|
||||
/** Registration-side business face for the header label. */
|
||||
@@ -49,10 +48,6 @@ export function AgentPresetLabel({
|
||||
return typeof value === 'string' ? value : undefined
|
||||
})
|
||||
const options = useAgentPresets(state => state.options)
|
||||
const [detailsOpen, setDetailsOpen] = useState(false)
|
||||
const option = options.find(entry => entry.id === preset)
|
||||
|
||||
useEffect(() => { setDetailsOpen(false) }, [sessionId, preset, option?.id])
|
||||
|
||||
useEffect(() => {
|
||||
// Deployments that compose no presets never label anything, so the roster
|
||||
@@ -62,28 +57,12 @@ export function AgentPresetLabel({
|
||||
|
||||
if (preset === undefined) return null
|
||||
|
||||
const option = options.find(entry => entry.id === preset)
|
||||
const text = option === undefined ? undefined : presetDisplayText(option, t)
|
||||
const label = text?.name ?? preset
|
||||
const guide = option === undefined ? undefined : presetGuide(option.id, isBuiltInPreset(option) ? 'system' : 'user')
|
||||
if (option === undefined) return (
|
||||
<span className={css.label} title={t('headerHint')}>
|
||||
return (
|
||||
<span className={css.label} title={text?.description ?? t('headerHint')}>
|
||||
<IconAgentPresetOutlineRegular size={14} className={css.icon} />
|
||||
<span className={css.name}>{label}</span>
|
||||
{text?.name ?? preset}
|
||||
</span>
|
||||
)
|
||||
return (
|
||||
<>
|
||||
<button type="button" className={`${css.label} ${css.details}`} title={text?.description ?? t('headerHint')}
|
||||
aria-label={`${t('modeExplanation')}: ${label}`} aria-haspopup="dialog" onClick={() => { setDetailsOpen(true) }}>
|
||||
<IconAgentPresetOutlineRegular size={14} className={css.icon} />
|
||||
<span className={css.name}>{label}</span>
|
||||
</button>
|
||||
{!detailsOpen ? null : guide === undefined ? (
|
||||
<Modal open title={label} description={option.description ?? t('noDescription')}
|
||||
closeLabel={t('close')} onClose={() => { setDetailsOpen(false) }} />
|
||||
) : (
|
||||
<PresetGuideDialog guide={guide} initialPage="explanation" t={t} onClose={() => { setDetailsOpen(false) }} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,39 +1,6 @@
|
||||
/* Agent-preset chip on the new-session screen, beside the workspace picker.
|
||||
Geometry mirrors HeroShell's .workspace so the two read as one row. */
|
||||
|
||||
.controls {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.details {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
width: 24px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--dsw-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.details:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.details:focus-visible {
|
||||
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Overflow-driven degradation (mirrors HeroShell's .workspace), no
|
||||
breakpoint: the label shrinks with an ellipsis down to nothing, so the
|
||||
preset icon and chevron always sit snug against whatever text remains —
|
||||
@@ -67,7 +34,7 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.seat:not(:disabled):hover,
|
||||
.seat:not(:disabled):hover,
|
||||
.seat[aria-expanded='true'] {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
@@ -77,10 +44,6 @@ button.seat:not(:disabled):hover,
|
||||
color: var(--dsw-alias-label-quaternary);
|
||||
}
|
||||
|
||||
.readOnly {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.seatIcon {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
|
||||
@@ -16,8 +16,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
IconAgentPresetOutlineRegular, IconChevronDownOutlineRegular, IconInfoOutlineRegular, IconWarningOutlineRegular,
|
||||
Menu, Modal, Toast, Tooltip,
|
||||
IconAgentPresetOutlineRegular, IconChevronDownOutlineRegular, IconWarningOutlineRegular, Menu, Modal, Toast,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
// Type-only: pulls the ui-conversation SlotMap merge (the hero seat).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
@@ -29,7 +28,7 @@ import css from './AgentPresetSeat.module.css'
|
||||
/** Registration-side business face for the hero chip. */
|
||||
export interface AgentPresetSeatInjected {
|
||||
hooks: {
|
||||
/** Shared Developer tools preference; off hides the chip. */
|
||||
/** Shared Developer tools preference; off hides ordinary preset selection. */
|
||||
developerTools: ObservableSnapshot<boolean>
|
||||
/** Seat snapshot bound by the renderer as useAgentPresetSeat. */
|
||||
agentPresetSeat: SnapshotStore<AgentPresetSeatState>
|
||||
@@ -104,12 +103,11 @@ export function AgentPresetSeat({
|
||||
useEffect(() => {
|
||||
if (developerTools) return
|
||||
setOpen(false)
|
||||
setDetailsOpen(false)
|
||||
setToast(null)
|
||||
}, [developerTools])
|
||||
|
||||
const chosen = state.options.find(option => option.id === state.current)
|
||||
useEffect(() => { setDetailsOpen(false) }, [state.current, chosen?.id])
|
||||
useEffect(() => { setDetailsOpen(false) }, [developerTools, state.current, chosen?.id])
|
||||
|
||||
const chosenText = chosen === undefined ? undefined : presetDisplayText(chosen, t)
|
||||
const label = chosenText?.name ?? state.current
|
||||
@@ -161,7 +159,7 @@ export function AgentPresetSeat({
|
||||
: label
|
||||
|
||||
return (
|
||||
<div className={css.controls}>
|
||||
<>
|
||||
{developerTools ? <Menu
|
||||
open={open}
|
||||
onClose={() => { setOpen(false) }}
|
||||
@@ -215,25 +213,14 @@ export function AgentPresetSeat({
|
||||
</button>
|
||||
)}
|
||||
/> : (
|
||||
<span className={`${css.seat} ${css.readOnly}`} title={state.error ?? chosenText?.description}>
|
||||
<button type="button" className={css.seat} title={state.error ?? chosenText?.description}
|
||||
aria-label={`${t('modeExplanation')}: ${label}`} aria-haspopup="dialog" disabled={chosen === undefined}
|
||||
onClick={() => { setDetailsOpen(true) }}>
|
||||
<IconAgentPresetOutlineRegular className={introducing ? `${css.seatIcon} ${css.introIcon}` : css.seatIcon} />
|
||||
<span className={css.seatLabel}>{shownLabel}</span>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{chosen === undefined ? null : (
|
||||
<Tooltip label={t('modeExplanation')} portal>
|
||||
<button
|
||||
type="button"
|
||||
className={css.details}
|
||||
aria-label={`${t('modeExplanation')}: ${label}`}
|
||||
aria-haspopup="dialog"
|
||||
onClick={() => { setOpen(false); setDetailsOpen(true) }}
|
||||
>
|
||||
<IconInfoOutlineRegular size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!detailsOpen || chosen === undefined ? null : guide === undefined ? (
|
||||
{!detailsOpen || developerTools || chosen === undefined ? null : guide === undefined ? (
|
||||
<Modal open title={label} description={chosen.description ?? t('noDescription')}
|
||||
closeLabel={t('close')} onClose={() => { setDetailsOpen(false) }} />
|
||||
) : (
|
||||
@@ -253,6 +240,6 @@ export function AgentPresetSeat({
|
||||
onDone={() => { setToast(null) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -49,12 +49,6 @@ function translate(key: keyof typeof en, params?: Record<string, unknown>): stri
|
||||
: template.replace(/\{(\w+)\}/g, (match, name: string) => name in params ? String(params[name]) : match)
|
||||
}
|
||||
|
||||
function seatTrigger(): HTMLElement {
|
||||
const button = screen.getAllByRole('button').find(item => item.getAttribute('aria-haspopup') === 'menu')
|
||||
if (button === undefined) throw new Error('No preset selection trigger')
|
||||
return button
|
||||
}
|
||||
|
||||
function renderSeat(
|
||||
state: Partial<AgentPresetSeatState> = {},
|
||||
select: () => Promise<string | undefined> = () => Promise.resolve(undefined),
|
||||
@@ -95,7 +89,7 @@ function renderLabel(
|
||||
useAgentPresets: bindSnapshotSelector(store),
|
||||
t: (key: keyof typeof en) => en[key],
|
||||
} as unknown as AgentPresetLabelProps)} />)
|
||||
return { load, view, store }
|
||||
return { load, view }
|
||||
}
|
||||
|
||||
describe('the new-session chip', () => {
|
||||
@@ -105,44 +99,11 @@ describe('the new-session chip', () => {
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('offers only read-only Creator help while Developer tools are off', () => {
|
||||
const actions = renderSeat({ current: 'cordis', options: [{ id: 'cordis' }, { id: 'standard' }] },
|
||||
undefined, undefined, false)
|
||||
expect(screen.getByText(en.presetCordisName)).toBeTruthy()
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
expect(document.querySelector('[aria-haspopup="menu"]')).toBeNull()
|
||||
const trigger = screen.getByRole('button', { name: `${en.modeExplanation}: ${en.presetCordisName}` })
|
||||
trigger.focus()
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.getByRole('dialog', { name: en.presetCordisName })).toBeTruthy()
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(document.activeElement).toBe(trigger)
|
||||
expect(actions.select).not.toHaveBeenCalled()
|
||||
expect(actions.developerTools.getSnapshot()).toBe(false)
|
||||
|
||||
act(() => { actions.store.set({ ...actions.store.getSnapshot(), current: 'standard' }) })
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
expect(screen.queryByText(en.presetCordisName)).toBeNull()
|
||||
})
|
||||
|
||||
it('removes the Creator picker when Coding Tools turn off but keeps read-only help', () => {
|
||||
const actions = renderSeat({ current: 'cordis', options: [{ id: 'cordis' }, { id: 'standard' }] })
|
||||
fireEvent.click(seatTrigger())
|
||||
expect(screen.getByRole('menu')).toBeTruthy()
|
||||
act(() => { actions.developerTools.set(false) })
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
expect(document.querySelector('[aria-haspopup="menu"]')).toBeNull()
|
||||
expect(screen.getByText(en.presetCordisName)).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: `${en.modeExplanation}: ${en.presetCordisName}` })).toBeTruthy()
|
||||
expect(actions.select).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders only for a Session retained by the main view', () => {
|
||||
renderSeat({}, undefined, {
|
||||
id: 's1', retainInfo: { referenceCount: 1, retainedBy: { mainView: 1 } },
|
||||
})
|
||||
expect(seatTrigger()).toBeTruthy()
|
||||
expect(screen.getByRole('button')).toBeTruthy()
|
||||
cleanup()
|
||||
|
||||
renderSeat({}, undefined, { id: 's1', retainInfo: undefined })
|
||||
@@ -153,14 +114,14 @@ describe('the new-session chip', () => {
|
||||
const actions = renderSeat()
|
||||
|
||||
await waitFor(() => { expect(actions.load).toHaveBeenCalledTimes(1) })
|
||||
expect(seatTrigger().textContent).toContain(en.presetStandardName)
|
||||
expect(seatTrigger().getAttribute('title')).toBe(en.seatHint)
|
||||
expect(screen.getByRole('button').textContent).toContain(en.presetStandardName)
|
||||
expect(screen.getByRole('button').getAttribute('title')).toBe(en.seatHint)
|
||||
})
|
||||
|
||||
it('offers each preset with what it is for', () => {
|
||||
renderSeat()
|
||||
|
||||
fireEvent.click(seatTrigger())
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
// The id alone never said what a preset does; the description is the
|
||||
// whole reason a preset can publish metadata at all.
|
||||
@@ -173,49 +134,49 @@ describe('the new-session chip', () => {
|
||||
|
||||
it('closes the picker immediately when developer tools turn off without changing the staged preset', () => {
|
||||
const actions = renderSeat()
|
||||
fireEvent.click(seatTrigger())
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(screen.getByText(en.presetStandardDescription)).toBeTruthy()
|
||||
act(() => { actions.developerTools.set(false) })
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
expect(screen.queryByText(en.presetStandardDescription)).toBeNull()
|
||||
expect(actions.select).not.toHaveBeenCalled()
|
||||
act(() => { actions.developerTools.set(true) })
|
||||
expect(seatTrigger().getAttribute('aria-expanded')).toBe('false')
|
||||
expect(seatTrigger().textContent).toContain(en.presetStandardName)
|
||||
expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false')
|
||||
expect(screen.getByRole('button').textContent).toContain(en.presetStandardName)
|
||||
})
|
||||
|
||||
it('falls back to the id when the staged preset published no name', () => {
|
||||
renderSeat({ current: 'mine' })
|
||||
|
||||
expect(seatTrigger().textContent).toContain('mine')
|
||||
expect(screen.getByRole('button').textContent).toContain('mine')
|
||||
})
|
||||
|
||||
it('shows the staged id until a stale roster contains it', () => {
|
||||
renderSeat({ current: 'arriving' })
|
||||
|
||||
expect(seatTrigger().textContent).toContain('arriving')
|
||||
expect(screen.getByRole('button').textContent).toContain('arriving')
|
||||
})
|
||||
|
||||
it('stages the picked preset and closes the menu', () => {
|
||||
const actions = renderSeat()
|
||||
fireEvent.click(seatTrigger())
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
fireEvent.click(screen.getByText('mine'))
|
||||
|
||||
expect(actions.select).toHaveBeenCalledWith('mine')
|
||||
expect(seatTrigger().getAttribute('aria-expanded')).toBe('false')
|
||||
expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('disables the trigger while a switch is in flight', () => {
|
||||
renderSeat({ busy: true })
|
||||
|
||||
expect(seatTrigger()).toHaveProperty('disabled', true)
|
||||
expect(screen.getByRole('button')).toHaveProperty('disabled', true)
|
||||
})
|
||||
|
||||
it('shows a refused switch on the trigger', () => {
|
||||
renderSeat({ error: 'session has already started' })
|
||||
|
||||
expect(seatTrigger().getAttribute('title')).toBe('session has already started')
|
||||
expect(screen.getByRole('button').getAttribute('title')).toBe('session has already started')
|
||||
})
|
||||
|
||||
it('renders nothing before the roster arrives or when there is none', () => {
|
||||
@@ -230,76 +191,73 @@ describe('the new-session chip', () => {
|
||||
|
||||
it('closes on an outside dismissal', () => {
|
||||
renderSeat()
|
||||
fireEvent.click(seatTrigger())
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
|
||||
expect(seatTrigger().getAttribute('aria-expanded')).toBe('false')
|
||||
expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
it('opens mode help without selecting and returns keyboard focus after Escape', () => {
|
||||
const actions = renderSeat()
|
||||
const trigger = screen.getByRole('button', { name: `${en.modeExplanation}: ${en.presetStandardName}` })
|
||||
trigger.focus()
|
||||
fireEvent.click(trigger)
|
||||
|
||||
expect(screen.getByRole('dialog', { name: en.presetStandardName })).toBeTruthy()
|
||||
expect(document.activeElement).toBe(screen.getByRole('tab', { name: en.modeExplanation }))
|
||||
fireEvent.click(screen.getByRole('tab', { name: en.howToUse }))
|
||||
expect(screen.getByRole('tab', { name: en.howToUse }).getAttribute('aria-selected')).toBe('true')
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
|
||||
|
||||
describe('Creator with Coding Tools off', () => {
|
||||
it('opens read-only help from the label without offering a preset switch or changing preferences', () => {
|
||||
const actions = renderSeat({ current: 'cordis', options: [{ id: 'cordis' }] }, undefined, undefined, false)
|
||||
const label = screen.getByRole('button', { name: `${en.modeExplanation}: ${en.presetCordisName}` })
|
||||
expect(label.getAttribute('aria-haspopup')).toBe('dialog')
|
||||
expect(document.querySelector('[aria-haspopup="menu"]')).toBeNull()
|
||||
label.focus()
|
||||
fireEvent.click(label)
|
||||
expect(screen.getByRole('dialog', { name: en.presetCordisName })).toBeTruthy()
|
||||
fireEvent.keyDown(screen.getByRole('tab', { name: en.modeExplanation }), { key: 'Escape' })
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(document.activeElement).toBe(trigger)
|
||||
expect(document.activeElement).toBe(label)
|
||||
expect(actions.select).not.toHaveBeenCalled()
|
||||
expect(actions.developerTools.getSnapshot()).toBe(false)
|
||||
|
||||
act(() => { actions.store.set({ ...SEAT_READY }) })
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('uses only a custom preset description even when it names a built-in id', () => {
|
||||
const actions = renderSeat({ options: [{ id: 'standard', name: 'My workflow', description: 'Our review workflow' }] })
|
||||
fireEvent.click(screen.getByRole('button', { name: `${en.modeExplanation}: My workflow` }))
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'My workflow' }).textContent).toContain('Our review workflow')
|
||||
expect(screen.queryByRole('tab')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: en.close }))
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(actions.select).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('explains a custom preset with no description without offering built-in guidance', () => {
|
||||
const actions = renderSeat({ current: 'mine' })
|
||||
fireEvent.click(screen.getByRole('button', { name: `${en.modeExplanation}: mine` }))
|
||||
expect(screen.getByRole('dialog', { name: 'mine' }).textContent).toContain(en.noDescription)
|
||||
expect(screen.queryByRole('tab')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: en.close }))
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(actions.select).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forgets open help when developer tools turn off', () => {
|
||||
const actions = renderSeat()
|
||||
fireEvent.click(screen.getByRole('button', { name: `${en.modeExplanation}: ${en.presetStandardName}` }))
|
||||
it('replaces an open picker with the read-only label and clears help when Coding Tools return', () => {
|
||||
const actions = renderSeat({ current: 'cordis', options: [{ id: 'cordis' }] })
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(screen.getByRole('menu')).toBeTruthy()
|
||||
act(() => { actions.developerTools.set(false) })
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(screen.getByRole('dialog')).toBeTruthy()
|
||||
act(() => { actions.developerTools.set(true) })
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(screen.getByRole('button').getAttribute('aria-haspopup')).toBe('menu')
|
||||
act(() => { actions.developerTools.set(false) })
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(actions.select).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ name: 'no presets', options: [] },
|
||||
{ name: 'another preset', options: [{ id: 'mine' }] },
|
||||
])('keeps help closed when the current preset returns after removal leaves $name', ({ options }) => {
|
||||
const actions = renderSeat()
|
||||
const before = actions.store.getSnapshot()
|
||||
fireEvent.click(screen.getByRole('button', { name: `${en.modeExplanation}: ${en.presetStandardName}` }))
|
||||
expect(screen.getByRole('dialog', { name: en.presetStandardName })).toBeTruthy()
|
||||
|
||||
act(() => { actions.store.set({ ...before, options }) })
|
||||
it.each<{ description?: string; expected: string }>([
|
||||
{ description: 'My own plugin authoring setup', expected: 'My own plugin authoring setup' },
|
||||
{ expected: en.noDescription },
|
||||
])('shows only declared help for a custom Creator preset: $expected', (copy) => {
|
||||
const option = { id: 'cordis', name: 'My creator', ...(copy.description === undefined ? {} : { description: copy.description }) }
|
||||
renderSeat({ current: 'cordis', options: [option] }, undefined, undefined, false)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(screen.getByRole('dialog', { name: 'My creator' }).textContent).toContain(copy.expected)
|
||||
expect(screen.queryByRole('tab')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: en.close }))
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
act(() => { actions.store.set(before) })
|
||||
})
|
||||
|
||||
it('closes help when the preset disappears and does not reopen it when the roster recovers', () => {
|
||||
const actions = renderSeat({ current: 'cordis', options: [{ id: 'cordis' }] }, undefined, undefined, false)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(screen.getByRole('dialog')).toBeTruthy()
|
||||
act(() => { actions.store.set({ ...actions.store.getSnapshot(), options: [{ id: 'standard' }], error: 'Unavailable' }) })
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(screen.getByRole('button', { name: `${en.modeExplanation}: ${en.presetStandardName}` })).toBeTruthy()
|
||||
expect(actions.select).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('button')).toHaveProperty('disabled', true)
|
||||
expect(screen.getByRole('button').getAttribute('title')).toBe('Unavailable')
|
||||
act(() => { actions.store.set({ ...actions.store.getSnapshot(), options: [{ id: 'cordis' }], error: null }) })
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(screen.getByRole('button')).toHaveProperty('disabled', false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -312,7 +270,7 @@ describe('a refused switch', () => {
|
||||
const reason = 'failed to import loader entry live-on-mac (@deepseek-ai/dsh-also-gone)'
|
||||
renderSeat({}, () => Promise.resolve(reason))
|
||||
|
||||
fireEvent.click(seatTrigger())
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /mine/ }))
|
||||
|
||||
// The host refuses a mount discovery reported healthy, so this banner is
|
||||
@@ -334,7 +292,7 @@ describe('a refused switch', () => {
|
||||
it('says nothing when the switch lands', async () => {
|
||||
const actions = renderSeat()
|
||||
|
||||
fireEvent.click(seatTrigger())
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /mine/ }))
|
||||
|
||||
await waitFor(() => { expect(actions.select).toHaveBeenCalledWith('mine') })
|
||||
@@ -350,22 +308,19 @@ describe('the chip introduce cue', () => {
|
||||
|
||||
/** Character spans carry inline animation delays; nothing else does. */
|
||||
function delayedChars(): HTMLElement[] {
|
||||
return Array.from(seatTrigger().querySelectorAll<HTMLElement>('[style]'))
|
||||
return Array.from(screen.getByRole('button').querySelectorAll<HTMLElement>('[style]'))
|
||||
}
|
||||
|
||||
it('announces a read-only Creator entry without exposing a picker or changing Coding Tools', () => {
|
||||
it('announces Creator on its read-only label while Coding Tools remain off', () => {
|
||||
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false })))
|
||||
vi.useFakeTimers()
|
||||
const actions = renderSeat({ current: 'cordis', options: [{ id: 'cordis' }], introduce: true },
|
||||
undefined, undefined, false)
|
||||
const label = screen.getByTitle(en.presetCordisDescription)
|
||||
expect(label.textContent).toBe(en.presetCordisName)
|
||||
expect(label.querySelectorAll('[style]').length).toBeGreaterThan(0)
|
||||
expect(document.querySelector('[aria-haspopup="menu"]')).toBeNull()
|
||||
|
||||
const actions = renderSeat({ current: 'cordis', options: [{ id: 'cordis' }], introduce: true }, undefined, undefined, false)
|
||||
expect(delayedChars().map(span => span.textContent).join('')).toBe(en.presetCordisName)
|
||||
expect(screen.getByRole('button').getAttribute('aria-haspopup')).toBe('dialog')
|
||||
expect(actions.introduced).not.toHaveBeenCalled()
|
||||
act(() => { vi.advanceTimersByTime(750) })
|
||||
expect(actions.introduced).toHaveBeenCalledOnce()
|
||||
expect(label.querySelectorAll('[style]')).toHaveLength(0)
|
||||
expect(actions.introduced).toHaveBeenCalledTimes(1)
|
||||
expect(delayedChars()).toHaveLength(0)
|
||||
expect(actions.developerTools.getSnapshot()).toBe(false)
|
||||
expect(actions.select).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -455,61 +410,11 @@ describe('the session-header label', () => {
|
||||
})
|
||||
|
||||
await waitFor(() => { expect(load).toHaveBeenCalledTimes(1) })
|
||||
// Reading details never offers a change to the task's preset.
|
||||
expect(screen.getByRole('button', { name: `${en.modeExplanation}: ${en.presetStandardName}` })).toBeTruthy()
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
// A control here would promise a switch the host refuses outright.
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
expect(screen.getByTitle(en.presetStandardDescription).textContent).toBe(en.presetStandardName)
|
||||
})
|
||||
|
||||
it('opens read-only mode help from a running task and restores focus on close', () => {
|
||||
renderLabel({ blank: false, projectionValues: { agentPreset: 'standard' } })
|
||||
const trigger = screen.getByRole('button', { name: `${en.modeExplanation}: ${en.presetStandardName}` })
|
||||
trigger.focus()
|
||||
fireEvent.click(trigger)
|
||||
|
||||
expect(screen.getByRole('dialog', { name: en.presetStandardName })).toBeTruthy()
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: en.close }))
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(document.activeElement).toBe(trigger)
|
||||
})
|
||||
|
||||
it('displays the custom description without claiming built-in capabilities', () => {
|
||||
renderLabel({ blank: false, projectionValues: { agentPreset: 'standard' } }, {
|
||||
options: [{ id: 'standard', name: 'Review only', description: 'Describe changes for review' }],
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: `${en.modeExplanation}: Review only` }))
|
||||
expect(screen.getByRole('dialog', { name: 'Review only' }).textContent).toContain('Describe changes for review')
|
||||
expect(screen.queryByRole('tab')).toBeNull()
|
||||
})
|
||||
|
||||
it('opens and closes help for a custom session preset that has no description', () => {
|
||||
renderLabel({ blank: false, projectionValues: { agentPreset: 'mine' } })
|
||||
const trigger = screen.getByRole('button', { name: `${en.modeExplanation}: mine` })
|
||||
trigger.focus()
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.getByRole('dialog', { name: 'mine' }).textContent).toContain(en.noDescription)
|
||||
expect(screen.queryByRole('tab')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: en.close }))
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(document.activeElement).toBe(trigger)
|
||||
})
|
||||
|
||||
it('keeps help closed when the session preset returns after leaving the roster', () => {
|
||||
const { store } = renderLabel({ blank: false, projectionValues: { agentPreset: 'standard' } })
|
||||
const before = store.getSnapshot()
|
||||
fireEvent.click(screen.getByRole('button', { name: `${en.modeExplanation}: ${en.presetStandardName}` }))
|
||||
expect(screen.getByRole('dialog', { name: en.presetStandardName })).toBeTruthy()
|
||||
|
||||
act(() => { store.set({ ...before, options: [{ id: 'mine' }] }) })
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(screen.getByTitle(en.headerHint).textContent).toBe('standard')
|
||||
act(() => { store.set(before) })
|
||||
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(screen.getByRole('button', { name: `${en.modeExplanation}: ${en.presetStandardName}` })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('falls back to the id, and to the generic hint, when metadata is absent', () => {
|
||||
renderLabel({ blank: true, projectionValues: { agentPreset: 'mine' } })
|
||||
|
||||
|
||||
@@ -18,11 +18,11 @@
|
||||
en: b7d2396da72ecac4
|
||||
zh: 4ec8336478dd39c7
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/installing-a-bundle:
|
||||
en: ea02185c3ec03a41
|
||||
zh: 0e1e5d641fa1c8f6
|
||||
en: 7fe665944e8b8377
|
||||
zh: faf09d639eca62dd
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/switching-a-bundle:
|
||||
en: a5f9da50f4fb3b23
|
||||
zh: c2160c918574616a
|
||||
en: abc90531efa889d5
|
||||
zh: 0562570f85e69c35
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/switching-one-row-of-a-bundle:
|
||||
en: 620c6a3ee20848bd
|
||||
zh: a9429f047a12bb4c
|
||||
@@ -30,8 +30,8 @@
|
||||
en: c32019330fd9586d
|
||||
zh: dd22110abd10159c
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/detail-page-extension-points:
|
||||
en: a5bd63206fa48e20
|
||||
zh: 335b8dac564554df
|
||||
en: 87f6d96467dc2383
|
||||
zh: e0fb91aa4c4d5a12
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/understand-the-implementation:
|
||||
en: 8ca48e1365f18adf
|
||||
zh: 91eadd683d2d8b5b
|
||||
|
||||
@@ -39,6 +39,8 @@ The **Refresh** button shows its tooltip after 500 ms of hovering or keyboard fo
|
||||
|
||||
The **Add plugin** primary button directly opens the existing installation dialog. Its separate arrow button, **Choose how to add a plugin**, opens a menu: **Install a third-party plugin** opens that same dialog with focus in its package-name field; plugin-contributed creation actions follow it. The two buttons have independent click targets and accessible names. Arrow keys move between menu rows, Escape closes back to the arrow button, and the menu stays within the viewport. While an installation is tracked, **View installation** opens that task directly.
|
||||
|
||||
`plugins.add.actions` adds root-scoped `MenuItemButton` rows after installation in this menu. Its owner supplies `onDismiss()`, which a contribution calls before starting its action.
|
||||
|
||||
On first use, when pnpm uses the unconfigured official npm registry and npmmirror is offered, the Host probes both registries in parallel. The first successful HTTPS ping response selects the initial source. The dialog preserves remembered choices, manual selections, explicit manager configuration, and custom or unknown pnpm registries. An install clicked during initial probing waits for that bounded operation; a late result cannot overwrite a manual choice or reopen a closed dialog.
|
||||
|
||||
The Host sends GET requests to `https://registry.npmjs.org/-/ping` and `https://registry.npmmirror.com/-/ping` through its normal fetch proxy. A 2xx response wins; redirects and failures do not. The remaining request is cancelled, both response bodies are discarded, and cleanup completes before the result returns. The default deadline is 1500 ms and results, including unavailable results, are cached for five minutes. Configure `registryProbeTimeoutMs` and `registryProbeCacheTtlMs` on `ui-plugin-manager`; `registryProbeEnabled: false` disables probing. If both requests fail or time out, the existing default remains selected. No IP-location service or Session content is involved.
|
||||
@@ -51,7 +53,7 @@ When the Host attributes a network failure or timeout to a GitHub address and of
|
||||
|
||||
### Switching a bundle
|
||||
|
||||
A bundle's page shows its readable row names and descriptions under **What this plugin provides**, with duplicate descriptions grouped once and unavailable metadata omitted. Its scope is all sessions in this deployment; each session's mode still determines its tools. **Components and runtime status** folds package names, row identifiers, switches, and diagnostics by default and opens when a package, metadata, or row failure is reported. Declared changes to existing rows appear as a count under **Changes to existing capabilities**, with their ids inside the folded section. A bundle's switch changes its layer selection. A profile with HMR recomposes before the operation completes; one without HMR, and a bundle a higher layer overrides, say so in a toast. A bundle the Host cannot read carries a problem tag and its reason on its page and cannot be switched on; one that provides the management components stays locked. The Host answers with error codes, which the page's dictionary words; pnpm's and the Loader's own diagnostics are shown as they are. The page excludes built-in profile bundles from cards and counts even when the profile holds them as dependencies or the Host reports an error. The Host inventory remains complete; the Settings Plugins section's Plugin list tab inspects their plugins.
|
||||
A bundle's page shows its full package name under the title, the spec that installs it elsewhere. A bundle's switch changes its layer selection. A profile with HMR recomposes before the operation completes; one without HMR, and a bundle a higher layer overrides, say so in a toast. A bundle the Host cannot read carries a problem tag and its reason on its page and cannot be switched on; one that provides the management components stays locked. The Host answers with error codes, which the page's dictionary words; pnpm's and the Loader's own diagnostics are shown as they are. The page excludes built-in profile bundles from cards and counts even when the profile holds them as dependencies or the Host reports an error. The Host inventory remains complete; the Settings Plugins section's Plugin list tab inspects their plugins.
|
||||
|
||||
### Switching one row of a bundle
|
||||
|
||||
@@ -73,8 +75,6 @@ The bundle's patch must declare the row under that id, and the registration exis
|
||||
|
||||
### Detail page extension points
|
||||
|
||||
`plugins.add.actions` adds root-scoped `MenuItemButton` rows after installation in the Add plugin menu. Its owner supplies `onDismiss()`, which a contribution calls before starting its action. `plugins.bundle.usage` is keyed by package name and receives `{ pkg: PluginPackageRef }`; it renders usage guidance and entry actions after the bundle description and before component details. Both follow the page declaration lifetime and accept contributions without importing feature components.
|
||||
|
||||
A plugin with something to say about a bundle, a row, or an official plugin it does not own contributes to that object's page through three list slots the page declares: `plugins.detail.actions` for a control at the head of the page, before the page's own switch and uninstall; `plugins.detail.badge` for a tag beside the title, after the version, experimental, and problem tags; and `plugins.detail.section` for a section under the page's own content — after the rows on a bundle's page, after the configuration on a row's or an official plugin's page. Every entry is rendered with the page's `subject`: `{ kind: 'bundle', pkg }`, `{ kind: 'row', pkg, row }`, or `{ kind: 'item', id }`, where `pkg` and `row` carry the name, version, installed and enabled facts, and the row list a contribution decides on. An entry renders null for a subject it has nothing for and draws its own section chrome; the page orders entries by `order`.
|
||||
|
||||
```tsx ignore-check
|
||||
|
||||
@@ -39,6 +39,8 @@ kind: "package-reference"
|
||||
|
||||
**添加插件**主按钮直接打开原安装对话框。旁边独立的箭头按钮**选择添加插件方式**打开菜单:**安装第三方插件**打开同一个对话框并聚焦包名输入框,插件贡献的创造动作接在其后。两个按钮有独立的点击区域和无障碍名称。方向键在菜单条目间移动,Escape 关闭菜单并返回箭头按钮,菜单位置受视口边界约束。已有安装任务时,**查看安装任务**直接打开该任务。
|
||||
|
||||
`plugins.add.actions` 在此菜单的安装项之后提供 root 作用域的 `MenuItemButton` 行。owner 提供 `onDismiss()`,贡献者在开始动作前调用它关闭菜单。
|
||||
|
||||
首次使用时,如果未显式配置安装源、pnpm 使用官方 npm 源,且列表提供 npmmirror,Host 会并发探测这两个源,选择最先成功响应 HTTPS ping 的源。已记住的选择、用户手选、管理器显式配置,以及自定义或未知的 pnpm 源均保留。在初次探测期间点击安装会等待这次有时限的操作;迟到结果不会覆盖手选或重新打开已关闭的对话框。
|
||||
|
||||
Host 通过普通 fetch 代理向 `https://registry.npmjs.org/-/ping` 和 `https://registry.npmmirror.com/-/ping` 发送 GET 请求,以最先返回 2xx 的源为结果;重定向和失败不参与选择。随后取消另一条请求、丢弃两个响应体,并在清理完成后返回。默认时限为 1500 毫秒,包括不可用结果在内的探测结果均缓存五分钟。在 `ui-plugin-manager` 上配置 `registryProbeTimeoutMs` 和 `registryProbeCacheTtlMs`;`registryProbeEnabled: false` 关闭探测。两条请求都失败或超时时保留现有默认源。不使用 IP 地区服务,也不发送 Session 内容。
|
||||
@@ -51,7 +53,7 @@ Host 将网络失败或超时归因于 GitHub 地址,且提供 npmmirror 时
|
||||
|
||||
### 切换一个组合包
|
||||
|
||||
组合包页面在**这个插件提供**中展示组件的人类可读名称和说明,相同内容只列一次,缺少元信息时不补造。作用范围是此部署的所有会话,每个会话可用的工具仍由它的模式决定。**组件与运行状态**默认折叠包名、行标识、开关和诊断,出现包错误、元信息错误或组件运行异常时展开。声明的现有组件调整以数量显示在**调整现有能力**中,具体行 id 位于折叠区。组合包开关改变其层选择。启用了 HMR 的 profile 在操作完成前重组;没有 HMR 的 profile,以及被更高层覆盖的组合包,会以 toast 说明。Host 读不了的组合包带异常标签,其页面给出原因,且不能打开;提供管理组件的组合包保持锁定。Host 以错误码作答,由页面字典措辞;pnpm 与 Loader 自己的诊断原样显示。页面从卡片与数量中排除内置 profile 组合包,即使 profile 将它们列为依赖或 Host 报告了异常。Host 清单仍保留完整数据;设置中「插件」分区的「插件列表」标签页负责查看它们的插件。
|
||||
组合包页面在标题下方显示完整包名,也就是在别处安装它所需的 spec。组合包开关改变其层选择。启用了 HMR 的 profile 在操作完成前重组;没有 HMR 的 profile,以及被更高层覆盖的组合包,会以 toast 说明。Host 读不了的组合包带异常标签,其页面给出原因,且不能打开;提供管理组件的组合包保持锁定。Host 以错误码作答,由页面字典措辞;pnpm 与 Loader 自己的诊断原样显示。页面从卡片与数量中排除内置 profile 组合包,即使 profile 将它们列为依赖或 Host 报告了异常。Host 清单仍保留完整数据;设置中「插件」分区的「插件列表」标签页负责查看它们的插件。
|
||||
|
||||
### 切换组合包里的一行
|
||||
|
||||
@@ -73,8 +75,6 @@ ctx.slots.inject('plugins.row.config', () => ctx.slots.register({
|
||||
|
||||
### 详情页扩展点
|
||||
|
||||
`plugins.add.actions` 在添加插件菜单的安装项之后提供 root 作用域的 `MenuItemButton` 行。owner 提供 `onDismiss()`,贡献者在开始动作前调用它关闭菜单。`plugins.bundle.usage` 以包名为键,接收 `{ pkg: PluginPackageRef }`,在组合包说明之后、组件详情之前渲染使用指引和入口动作。两者随页面声明的生命周期存在,贡献者无需导入其他功能组件。
|
||||
|
||||
对某个不属于自己的组合包、行或官方插件有话要说的插件,通过本页声明的三个 list slot 向该对象的页面贡献内容:`plugins.detail.actions` 在页头放一个控件,位于页面自己的开关和卸载之前;`plugins.detail.badge` 在标题旁放一个标签,位于版本、实验性和异常标签之后;`plugins.detail.section` 在页面自身内容之下放一个区块——组合包页在组件列表之后,行页和官方插件页在配置之后。每个条目都以页面的 `subject` 渲染:`{ kind: 'bundle', pkg }`、`{ kind: 'row', pkg, row }` 或 `{ kind: 'item', id }`,其中 `pkg` 与 `row` 携带包名、版本、是否已安装、是否启用以及行列表这些供贡献者判断的事实。条目对无话可说的 subject 返回 null,自绘区块外观;页面按 `order` 排列条目。
|
||||
|
||||
```tsx ignore-check
|
||||
|
||||
@@ -1292,89 +1292,6 @@ label.registryOption {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.scope {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.capabilities,
|
||||
.overrideIds {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.capability {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding-left: 12px;
|
||||
border-left: 0.5px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.capabilityTitle {
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.capabilityDescription {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.componentsToggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.componentsToggle[aria-expanded='false'] .detailsChevron {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.componentsToggle:focus-visible {
|
||||
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.componentsBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.componentsBody[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.overrideIds {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.sectionHead {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
||||
@@ -41,7 +41,7 @@ export type PluginManagerPageProps =
|
||||
& PropsLocale<'pluginManager'>
|
||||
& PropsRenderSlots<
|
||||
| 'plugins.item' | 'plugins.bundle.config' | 'plugins.row.config' | 'plugins.bundle.activation'
|
||||
| 'plugins.add.actions' | 'plugins.bundle.usage'
|
||||
| 'plugins.add.actions'
|
||||
| 'plugins.detail.actions' | 'plugins.detail.badge' | 'plugins.detail.section'
|
||||
>
|
||||
& InjectFace<PluginManagerFace>
|
||||
@@ -246,6 +246,7 @@ function RowsSection({ rows, t, resolveText, toggle, configure }: {
|
||||
<section className={css.detailSection} data-plugin-rows>
|
||||
<div className={css.sectionHead}>
|
||||
<h4 className={css.sectionTitle}>{t('partsLabel')}</h4>
|
||||
{rows.length === 0 ? null : <span className={css.sectionCount}>{partsSummary(rows, t)}</span>}
|
||||
</div>
|
||||
{rows.length === 0 ? <p className={css.status}>{t('partsEmpty')}</p> : null}
|
||||
{rows.length > ROW_FILTER_THRESHOLD
|
||||
@@ -570,44 +571,13 @@ function RowDetail({ pkg, row, t, resolveText, onBack, renderSlot, form }: {
|
||||
)
|
||||
}
|
||||
|
||||
/** Human-readable row metadata, without technical identifiers or runtime controls. */
|
||||
function CapabilitiesSummary({ rows, resolveText, t }: {
|
||||
readonly rows: readonly PackageRow[]
|
||||
readonly resolveText: ResolveText
|
||||
readonly t: Translate
|
||||
}): ReactNode {
|
||||
const seen = new Set<string>()
|
||||
const capabilities = rows.flatMap((row) => {
|
||||
const metadataTitle = row.meta?.title === undefined ? undefined : resolveText(row.meta.title).trim()
|
||||
const title = metadataTitle === row.moduleName || metadataTitle === row.rowId ? undefined : metadataTitle
|
||||
const description = row.meta?.description === undefined ? undefined : resolveText(row.meta.description).trim() || undefined
|
||||
if (title === undefined && description === undefined) return []
|
||||
const identity = JSON.stringify([title, description])
|
||||
if (seen.has(identity)) return []
|
||||
seen.add(identity)
|
||||
return [{ rowId: row.rowId, title, description }]
|
||||
})
|
||||
if (capabilities.length === 0) return null
|
||||
return (
|
||||
<section className={css.detailSection} data-plugin-capabilities>
|
||||
<h4 className={css.sectionTitle}>{t('capabilitiesTitle')}</h4>
|
||||
<ul className={css.capabilities}>
|
||||
{capabilities.map(({ rowId, title, description }) => (
|
||||
<li key={rowId} className={css.capability}>
|
||||
{title === undefined ? null : <span className={css.capabilityTitle}>{title}</span>}
|
||||
{description === undefined || description === title ? null : <span className={css.capabilityDescription}>{description}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One package's page: the crumb back to the list; its icon with its switch
|
||||
* and, for a package the profile installed, uninstall; its title beside its
|
||||
* version, beta, and problem tags; usage guidance, readable capabilities,
|
||||
* configuration, and component diagnostics folded until requested or failed.
|
||||
* version tag, its beta tag, and its problem tag; the package name the title
|
||||
* stands for, which is what installs it elsewhere; its one-liner; the Host's
|
||||
* problem when it reports one; the configuration the bundle registered for
|
||||
* itself; and its rows with their switches and configure controls.
|
||||
*/
|
||||
function PackageDetail({
|
||||
pkg, t, resolveText, busy, rowBusy, configured, configure, renderSlot,
|
||||
@@ -631,11 +601,6 @@ function PackageDetail({
|
||||
const { title, description, beta } = packageText(pkg, resolveText)
|
||||
const status = packageStatus(pkg)
|
||||
const subject: PluginsSubject = { kind: 'bundle', pkg: packageRef(pkg) }
|
||||
const hasProblem = pkg.error !== undefined || pkg.meta?.error !== undefined
|
||||
|| pkg.rows.some(row => row.phase === 'failed' || row.meta?.error !== undefined)
|
||||
const [componentsOpen, setComponentsOpen] = useState(hasProblem)
|
||||
const componentsId = useId()
|
||||
useEffect(() => { if (hasProblem) setComponentsOpen(true) }, [hasProblem])
|
||||
return (
|
||||
<div className={css.detail} data-plugin-detail={pkg.name}>
|
||||
<DetailTop
|
||||
@@ -673,28 +638,13 @@ function PackageDetail({
|
||||
{status === 'problem' ? <Tag className={css.statusTag} tone="danger">{t('statusProblem')}</Tag> : null}
|
||||
{renderSlot('plugins.detail.badge', { subject })}
|
||||
</div>
|
||||
<p className={css.detailName}><code data-plugin-name>{pkg.name}</code></p>
|
||||
{description === undefined ? null : <p className={css.detailDesc}>{description}</p>}
|
||||
<div className={css.scope}>
|
||||
<span>{t('deploymentScope')}</span>
|
||||
<Tooltip label={t('deploymentScopeDescription')} side="bottom" maxWidth={300} portal openOnClick>
|
||||
<Button variant="ghost" size="sm" className={css.infoButton} aria-label={t('deploymentScopeDescription')}>
|
||||
<IconInfoOutlineRegular size={11} aria-hidden="true" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<MetadataError error={pkg.meta?.error} t={t} />
|
||||
{pkg.error === undefined ? null : <p className={css.reason} role="status">{t('reasonLabel')}: {managementText(pkg.error, t)}</p>}
|
||||
{pkg.readOnlyReason === undefined ? null : <p className={css.reason} role="status">{managementText({ code: pkg.readOnlyReason }, t)}</p>}
|
||||
<div className={css.detailSections}>
|
||||
{renderSlot('plugins.bundle.usage', { pkg: subject.pkg }, { entryKey: pkg.name })}
|
||||
<CapabilitiesSummary rows={pkg.rows} t={t} resolveText={resolveText} />
|
||||
{pkg.overrides.length === 0 ? null : (
|
||||
<section className={css.detailSection} data-plugin-overrides>
|
||||
<h4 className={css.sectionTitle}>{t('overridesTitle')}</h4>
|
||||
<p className={css.capabilityDescription}>{t('overridesDescription', { count: String(pkg.overrides.length) })}</p>
|
||||
</section>
|
||||
)}
|
||||
{configured
|
||||
? (
|
||||
<section className={css.detailSection} data-plugin-config>
|
||||
@@ -702,32 +652,13 @@ function PackageDetail({
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
<section className={css.detailSection} data-plugin-components>
|
||||
<button type="button" className={css.componentsToggle} aria-expanded={componentsOpen} aria-controls={componentsId}
|
||||
onClick={() => { setComponentsOpen(value => !value) }}>
|
||||
<IconChevronDownOutlineRegular size={14} className={css.detailsChevron} aria-hidden="true" />
|
||||
<span>{t('componentsDisclosure')}</span>
|
||||
<span className={css.sectionCount}>{partsSummary(pkg.rows, t)}</span>
|
||||
</button>
|
||||
<div id={componentsId} className={css.componentsBody} hidden={!componentsOpen}>
|
||||
{componentsOpen ? <>
|
||||
<p className={css.detailName}><code data-plugin-name>{pkg.name}</code></p>
|
||||
<RowsSection
|
||||
rows={pkg.rows}
|
||||
t={t}
|
||||
resolveText={resolveText}
|
||||
toggle={pkg.enabled ? { busy: row => busy || rowBusy(row), onSetEnabled: onSetRowEnabled } : undefined}
|
||||
configure={configure}
|
||||
/>
|
||||
{pkg.overrides.length === 0 ? null : (
|
||||
<section className={css.detailSection}>
|
||||
<h4 className={css.sectionTitle}>{t('overridesComponents')}</h4>
|
||||
<ul className={css.overrideIds}>{pkg.overrides.map(id => <li key={id}><code>{id}</code></li>)}</ul>
|
||||
</section>
|
||||
)}
|
||||
</> : null}
|
||||
</div>
|
||||
</section>
|
||||
<RowsSection
|
||||
rows={pkg.rows}
|
||||
t={t}
|
||||
resolveText={resolveText}
|
||||
toggle={pkg.enabled ? { busy: row => busy || rowBusy(row), onSetEnabled: onSetRowEnabled } : undefined}
|
||||
configure={configure}
|
||||
/>
|
||||
{renderSlot('plugins.detail.section', { subject })}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1511,7 +1442,6 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
|
||||
{loaded && openPkg !== undefined && openRow === undefined
|
||||
? (
|
||||
<PackageDetail
|
||||
key={openPkg.name}
|
||||
pkg={openPkg}
|
||||
t={t}
|
||||
resolveText={resolveText}
|
||||
|
||||
@@ -48,7 +48,7 @@ export type { ConfigLedger, OfficialItem } from './config-ledger.ts'
|
||||
export type { PluginManagerFace } from './manager-store.ts'
|
||||
export type { PluginManagerLocaleKey } from './locales.ts'
|
||||
export type {
|
||||
ConfigPageForm, PluginActivationOwnerProps, PluginBundleUsageProps, PluginAddActionsProps,
|
||||
ConfigPageForm, PluginActivationOwnerProps, PluginAddActionsProps,
|
||||
PluginConfigViewProps, PluginDetailProps, PluginPackageRef, PluginRowRef, PluginsSubject,
|
||||
} from './slot-contract.ts'
|
||||
|
||||
@@ -119,7 +119,6 @@ export function apply(ctx: ClientContext): void {
|
||||
children: {
|
||||
'plugins.add.actions': { kind: 'list', scope: 'root' },
|
||||
'plugins.item': { kind: 'list', scope: 'root' },
|
||||
'plugins.bundle.usage': { kind: 'keyed', scope: 'root' },
|
||||
'plugins.bundle.activation': { kind: 'keyed', scope: 'root' },
|
||||
'plugins.bundle.config': { kind: 'keyed', scope: 'root' },
|
||||
'plugins.row.config': { kind: 'keyed', scope: 'root' },
|
||||
|
||||
@@ -32,13 +32,6 @@ export const zh = {
|
||||
reasonLabel: '原因',
|
||||
metadataError: '包元信息错误:{error}',
|
||||
versionTag: 'v{version}',
|
||||
capabilitiesTitle: '这个插件提供',
|
||||
deploymentScope: '作用范围:当前 DSH',
|
||||
deploymentScopeDescription: '启用会为当前 DSH 添加能力;每个会话可用的工具仍由它的模式决定',
|
||||
componentsDisclosure: '组件与运行状态',
|
||||
overridesTitle: '调整现有能力',
|
||||
overridesDescription: '此插件包含对 {count} 个现有组件的配置或启用状态调整',
|
||||
overridesComponents: '调整的组件',
|
||||
partsLabel: '包含的组件',
|
||||
partsEmpty: '这个插件包不包含任何组件。',
|
||||
partsCountTotal: '共 {count} 个',
|
||||
@@ -239,13 +232,6 @@ export const en = {
|
||||
reasonLabel: 'Reason',
|
||||
metadataError: 'Package metadata error: {error}',
|
||||
versionTag: 'v{version}',
|
||||
capabilitiesTitle: 'What this plugin provides',
|
||||
deploymentScope: 'Scope: this DSH',
|
||||
deploymentScopeDescription: 'Enabling adds capabilities to this DSH; each session’s mode still determines its available tools.',
|
||||
componentsDisclosure: 'Components and runtime status',
|
||||
overridesTitle: 'Changes to existing capabilities',
|
||||
overridesDescription: 'This plugin includes configuration or enablement changes for {count} existing components.',
|
||||
overridesComponents: 'Components changed',
|
||||
partsLabel: 'Components',
|
||||
partsEmpty: 'This plugin pack contains no components.',
|
||||
partsCountTotal: '{count} total',
|
||||
|
||||
@@ -94,8 +94,6 @@ export interface PackageView {
|
||||
/** Why the Host cannot read the bundle, when it cannot. */
|
||||
readonly error?: ManagementError
|
||||
readonly rows: readonly PackageRow[]
|
||||
/** Existing row ids whose configuration or enablement the bundle's patch changes. */
|
||||
readonly overrides: readonly string[]
|
||||
}
|
||||
|
||||
/** The typed spec as the Host read it, on the installing, installed, and failed screens. */
|
||||
@@ -425,7 +423,6 @@ export function packageView(bundle: BundleInfo, plugins: readonly PluginInfo[]):
|
||||
optional: bundle.optional,
|
||||
enabled: bundle.enabled,
|
||||
rows,
|
||||
overrides: bundle.overrides,
|
||||
...bundle.version === undefined ? {} : { version: bundle.version },
|
||||
...bundle.description === undefined ? {} : { description: bundle.description },
|
||||
...bundle.meta === undefined ? {} : { meta: bundle.meta },
|
||||
|
||||
@@ -78,18 +78,10 @@ export interface PluginAddActionsProps {
|
||||
readonly onDismiss: () => void
|
||||
}
|
||||
|
||||
/** Bundle facts supplied to its usage guidance and entry actions. */
|
||||
export interface PluginBundleUsageProps {
|
||||
/** The bundle whose detail page is open. */
|
||||
readonly pkg: PluginPackageRef
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
/** Additional MenuItemButton rows after installation in the add-plugin menu. */
|
||||
'plugins.add.actions': { kind: 'list'; scope: 'root'; owner: PluginAddActionsProps }
|
||||
/** Bundle-owned usage guidance below the description, keyed by npm package name. */
|
||||
'plugins.bundle.usage': { kind: 'keyed'; scope: 'root'; owner: PluginBundleUsageProps }
|
||||
/** Optional guidance after the user enables a bundle from the list, keyed by npm package name. */
|
||||
'plugins.bundle.activation': { kind: 'keyed'; scope: 'root'; owner: PluginActivationOwnerProps }
|
||||
/**
|
||||
|
||||
@@ -62,30 +62,6 @@ function declare(slots: SlotRegistry): () => void {
|
||||
}
|
||||
|
||||
describe('ui-plugin-manager browser plugin', () => {
|
||||
it('reattaches add-plugin menu actions and bundle usage across page declaration lifetimes', async () => {
|
||||
const b = await bench()
|
||||
b.slots.inject('plugins.add.actions', () => b.slots.register({
|
||||
name: 'plugins.add.actions', id: 'create-plugin',
|
||||
}, () => null))
|
||||
b.slots.inject('plugins.bundle.usage', () => b.slots.register({
|
||||
name: 'plugins.bundle.usage', key: '@acme/notes',
|
||||
}, () => null))
|
||||
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
const removeRoot = declare(b.slots)
|
||||
expect(b.slots.entries('plugins.add.actions')).toHaveLength(1)
|
||||
expect(b.slots.entries('plugins.bundle.usage')).toHaveLength(1)
|
||||
removeRoot()
|
||||
expect(b.slots.entries('plugins.add.actions')).toHaveLength(0)
|
||||
expect(b.slots.entries('plugins.bundle.usage')).toHaveLength(0)
|
||||
declare(b.slots)
|
||||
expect(b.slots.entries('plugins.add.actions')).toHaveLength(1)
|
||||
expect(b.slots.entries('plugins.bundle.usage')).toHaveLength(1)
|
||||
await fiber.dispose()
|
||||
expect(b.slots.entries('plugins.add.actions')).toHaveLength(0)
|
||||
expect(b.slots.entries('plugins.bundle.usage')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('resets bundle selection when leaving Plugins and releases its panel observer with the registration', async () => {
|
||||
const b = await bench()
|
||||
const removeRoot = declare(b.slots)
|
||||
@@ -171,9 +147,8 @@ describe('ui-plugin-manager browser plugin', () => {
|
||||
expect(icon.locale).toBe(NS)
|
||||
expect(resolveSlotLabel(icon.options.label)).toBe('插件')
|
||||
// The page declares the slots a plugin's configuration arrives through, and binds their projection beside its state.
|
||||
expect(b.slots.spec('plugins.item')).toMatchObject({ kind: 'list', scope: 'root' })
|
||||
expect(b.slots.spec('plugins.add.actions')).toMatchObject({ kind: 'list', scope: 'root' })
|
||||
expect(b.slots.spec('plugins.bundle.usage')).toMatchObject({ kind: 'keyed', scope: 'root' })
|
||||
expect(b.slots.spec('plugins.item')).toMatchObject({ kind: 'list', scope: 'root' })
|
||||
expect(b.slots.spec('plugins.bundle.config')).toMatchObject({ kind: 'keyed', scope: 'root' })
|
||||
expect(b.slots.spec('plugins.row.config')).toMatchObject({ kind: 'keyed', scope: 'root' })
|
||||
for (const name of ['plugins.detail.actions', 'plugins.detail.badge', 'plugins.detail.section'] as const) {
|
||||
|
||||
@@ -17,7 +17,7 @@ import type { PluginManagerPageProps } from '../src/client/index.ts'
|
||||
import type { ConfigLedger } from '../src/client/config-ledger.ts'
|
||||
import { rowKey, type InstallState, type PackageRow, type PackageView, type PluginManagerState } from '../src/client/manager-store.ts'
|
||||
import { en, zh, type PluginManagerLocaleKey } from '../src/client/locales.ts'
|
||||
import type { PluginActivationOwnerProps, PluginAddActionsProps, PluginBundleUsageProps, PluginDetailProps, PluginsSubject } from '../src/client/slot-contract.ts'
|
||||
import type { PluginActivationOwnerProps, PluginAddActionsProps, PluginDetailProps, PluginsSubject } from '../src/client/slot-contract.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -37,7 +37,6 @@ function pkg(overrides: Partial<PackageView> = {}): PackageView {
|
||||
optional: false,
|
||||
enabled: true,
|
||||
rows: [],
|
||||
overrides: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -83,7 +82,7 @@ const READY: PluginManagerState = {
|
||||
* (a list slot's cell is empty), the view asked for — `detail` for a detail
|
||||
* contribution — and the owner props.
|
||||
*/
|
||||
type SlotBodies = Record<string, (view: 'summary' | 'page' | 'activation' | 'detail' | 'usage' | 'add', owner: unknown, form?: ConfigPageForm) => ReactNode>
|
||||
type SlotBodies = Record<string, (view: 'summary' | 'page' | 'activation' | 'detail' | 'add', owner: unknown, form?: ConfigPageForm) => ReactNode>
|
||||
|
||||
/** The subject a detail contribution was rendered with. */
|
||||
function subjectOf(owner: unknown): PluginsSubject | undefined {
|
||||
@@ -160,7 +159,6 @@ function renderTab(
|
||||
const body = bodies[`${name}:${opts?.only ?? opts?.entryKey ?? ''}`]
|
||||
if (body === undefined) return null
|
||||
if (name === 'plugins.bundle.activation') return body('activation', owner)
|
||||
if (name === 'plugins.bundle.usage') return body('usage', owner)
|
||||
if (name === 'plugins.add.actions') return body('add', owner)
|
||||
if (name.startsWith('plugins.detail.')) return body('detail', owner)
|
||||
if (!('view' in owner) || (owner.view !== 'summary' && owner.view !== 'page')) {
|
||||
@@ -194,34 +192,16 @@ function renderTab(
|
||||
}
|
||||
}
|
||||
|
||||
/** Open the technical controls before exercising component-specific behavior. */
|
||||
function expandComponents(dict: typeof en = en): void {
|
||||
const toggle = screen.getByRole('button', { name: new RegExp(dict.componentsDisclosure) })
|
||||
if (toggle.getAttribute('aria-expanded') === 'false') fireEvent.click(toggle)
|
||||
}
|
||||
|
||||
describe('PluginManagerPage', () => {
|
||||
it('keeps contributed creation inside Add plugin and usage before folded implementation details', () => {
|
||||
const uses: PluginBundleUsageProps[] = []
|
||||
const createPlugin = vi.fn()
|
||||
const { actions } = renderTab({ packages: [pkg({ meta: { description: 'Browse your project notes.' }, rows: [row({
|
||||
meta: { title: 'Project notes', description: 'Read Markdown notes in this workspace.' },
|
||||
})] })] }, {}, {
|
||||
'plugins.add.actions:': (_view, owner) => <MenuItemButton onSelect={() => {
|
||||
(owner as PluginAddActionsProps).onDismiss()
|
||||
createPlugin()
|
||||
}}>Create a plugin</MenuItemButton>,
|
||||
'plugins.bundle.usage:dsh-better-sidebar': (_view, owner) => {
|
||||
uses.push(owner as PluginBundleUsageProps)
|
||||
return <button type="button">Open project notes</button>
|
||||
},
|
||||
it('opens the add-plugin menu with ArrowDown and returns focus to its trigger on Escape', () => {
|
||||
const { actions } = renderTab({}, {}, {
|
||||
'plugins.add.actions:': (_view, owner) => <MenuItemButton
|
||||
onSelect={() => { (owner as PluginAddActionsProps).onDismiss() }}>Create a plugin</MenuItemButton>,
|
||||
})
|
||||
expect(screen.queryByRole('menuitem', { name: 'Create a plugin' })).toBeNull()
|
||||
const add = screen.getByRole('button', { name: en.chooseAddMethod })
|
||||
add.focus()
|
||||
expect(fireEvent.keyDown(add, { key: 'Tab' })).toBe(true)
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
expect(actions.openInstall).not.toHaveBeenCalled()
|
||||
fireEvent.keyDown(add, { key: 'ArrowDown' })
|
||||
const install = screen.getByRole('menuitem', { name: new RegExp(en.installExisting) })
|
||||
const create = screen.getByRole('menuitem', { name: 'Create a plugin' })
|
||||
@@ -232,28 +212,7 @@ describe('PluginManagerPage', () => {
|
||||
fireEvent.keyDown(create, { key: 'Escape' })
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
expect(document.activeElement).toBe(add)
|
||||
fireEvent.click(add)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Create a plugin' }))
|
||||
expect(createPlugin).toHaveBeenCalledOnce()
|
||||
expect(actions.openInstall).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') }))
|
||||
expect(screen.queryByRole('button', { name: en.addPlugin })).toBeNull()
|
||||
const usage = screen.getByRole('button', { name: 'Open project notes' })
|
||||
expect(uses.at(-1)?.pkg).toMatchObject({ name: 'dsh-better-sidebar', enabled: true })
|
||||
expect(screen.getByText(en.deploymentScope)).toBeTruthy()
|
||||
expect(screen.getByRole('heading', { name: en.capabilitiesTitle })).toBeTruthy()
|
||||
expect(screen.getByText('Project notes')).toBeTruthy()
|
||||
expect(screen.getByText('Read Markdown notes in this workspace.')).toBeTruthy()
|
||||
expect(document.querySelector('[data-plugin-name]')).toBeNull()
|
||||
expect(document.querySelector('[data-plugin-row]')).toBeNull()
|
||||
const toggle = screen.getByRole('button', { name: new RegExp(en.componentsDisclosure) })
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(usage.compareDocumentPosition(toggle) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
fireEvent.click(toggle)
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(document.querySelector('[data-plugin-name]')?.textContent).toBe('dsh-better-sidebar')
|
||||
expect(screen.getByRole('switch', { name: en.partToggle.replace('{name}', 'Project notes') })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('opens the existing install dialog from the menu and keeps focus on its input', async () => {
|
||||
@@ -316,37 +275,6 @@ describe('PluginManagerPage', () => {
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
})
|
||||
|
||||
it('deduplicates readable capabilities and reports declared overrides without exposing ids until expanded', () => {
|
||||
const { setLanguage } = renderTab({ packages: [pkg({
|
||||
rows: [row({ meta: { title: { en: 'Notes', zh: '笔记' }, description: { en: 'Browse notes.', zh: '浏览笔记' } } }),
|
||||
row({ rowId: 'duplicate', meta: { title: { en: 'Notes', zh: '笔记' }, description: { en: 'Browse notes.', zh: '浏览笔记' } } }),
|
||||
row({ rowId: 'technical' })],
|
||||
overrides: ['system-prompt', 'tools'],
|
||||
})] })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') }))
|
||||
const capabilities = document.querySelector('[data-plugin-capabilities]') as HTMLElement
|
||||
expect(within(capabilities).getAllByRole('listitem')).toHaveLength(1)
|
||||
expect(screen.getByText(en.overridesDescription.replace('{count}', '2'))).toBeTruthy()
|
||||
expect(screen.queryByText('system-prompt')).toBeNull()
|
||||
setLanguage(zh)
|
||||
expect(within(capabilities).getByText('笔记')).toBeTruthy()
|
||||
expect(within(capabilities).getByText('浏览笔记')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: new RegExp(zh.componentsDisclosure) }))
|
||||
expect(screen.getByText('system-prompt')).toBeTruthy()
|
||||
expect(screen.getByText('tools')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('opens component diagnostics when a running row fails and omits absent capability metadata', () => {
|
||||
const { set } = renderTab({ packages: [pkg({ rows: [row()] })] })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') }))
|
||||
expect(document.querySelector('[data-plugin-capabilities]')).toBeNull()
|
||||
expect(document.querySelector('[data-plugin-overrides]')).toBeNull()
|
||||
expect(document.querySelector('[data-plugin-row]')).toBeNull()
|
||||
set({ packages: [pkg({ rows: [row({ phase: 'failed' })] })] })
|
||||
expect(screen.getByRole('button', { name: new RegExp(en.componentsDisclosure) }).getAttribute('aria-expanded')).toBe('true')
|
||||
expect(document.querySelector('[data-plugin-row]')?.getAttribute('data-state')).toBe('failed')
|
||||
})
|
||||
|
||||
it('opens the requested bundle after its inventory arrives and falls back when it is absent', () => {
|
||||
const b = renderTab({ status: 'loading' })
|
||||
act(() => { b.navigation.actions.setView({ kind: 'package', name: 'dsh-better-sidebar' }) })
|
||||
@@ -644,7 +572,6 @@ describe('PluginManagerPage', () => {
|
||||
fireEvent.click(screen.getByRole('switch', { name: zh.enableToggle.replace('{name}', title(zh)) }))
|
||||
expect(actions.setEnabled).toHaveBeenCalledExactlyOnceWith(name, false)
|
||||
fireEvent.click(screen.getByRole('button', { name: zh.openDetail.replace('{name}', title(zh)) }))
|
||||
expandComponents(zh)
|
||||
for (const dict of [zh, en]) {
|
||||
setLanguage(dict)
|
||||
expect(screen.getByRole('heading', { level: 3 }).textContent).toBe(title(dict))
|
||||
@@ -679,7 +606,6 @@ describe('PluginManagerPage', () => {
|
||||
set({ packages: [bundle] })
|
||||
expect(image().getAttribute('src')).toBe(icon)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'View dsh-better-sidebar' }))
|
||||
expandComponents()
|
||||
expect(image().getAttribute('src')).toBe(icon)
|
||||
const rowImage = document.querySelector<HTMLImageElement>('[data-plugin-row] img')!
|
||||
expect(rowImage.getAttribute('src')).toBe(icon)
|
||||
@@ -707,7 +633,6 @@ describe('PluginManagerPage', () => {
|
||||
fireEvent.click(enable)
|
||||
expect(actions.setEnabled).toHaveBeenCalledExactlyOnceWith('dsh-better-sidebar', true)
|
||||
fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') }))
|
||||
expandComponents(en)
|
||||
expect(screen.getByText(en.metadataError.replace('{error}', error))).toBeTruthy()
|
||||
const uninstall = screen.getByRole('button', { name: en.uninstallLabel.replace('{name}', 'dsh-better-sidebar') })
|
||||
expect(uninstall).toHaveProperty('disabled', false)
|
||||
@@ -787,7 +712,6 @@ describe('PluginManagerPage', () => {
|
||||
{ 'plugins.row.config:dsh-better-sidebar#theme': view => view === 'page' ? <form aria-label="theme settings" /> : null },
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'Bundle title') }))
|
||||
expandComponents(en)
|
||||
expect(screen.getByRole('switch', { name: en.partToggle.replace('{name}', '@acme/dsh-sidebar/navigation') })).toBeTruthy()
|
||||
expect(screen.getByRole('switch', { name: en.partToggle.replace('{name}', '@acme/dsh-theme') })).toBeTruthy()
|
||||
expect(screen.queryByText('中文主题说明。')).toBeNull()
|
||||
@@ -798,7 +722,7 @@ describe('PluginManagerPage', () => {
|
||||
expect(within(navigation).queryByText('Bundle description.')).toBeNull()
|
||||
fireEvent.click(within(navigation).getByRole('switch', { name: zh.partToggle.replace('{name}', '@acme/dsh-sidebar/navigation') }))
|
||||
expect(actions.setRowEnabled).toHaveBeenCalledExactlyOnceWith('include:sidebar', false)
|
||||
expect(within(document.querySelector('[data-plugin-rows]') as HTMLElement).getByText('中文主题说明。')).toBeTruthy()
|
||||
expect(screen.getByText('中文主题说明。')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: zh.configureRow.replace('{name}', '主题插件') }))
|
||||
expect(document.querySelector('[data-plugin-row-detail]')?.getAttribute('data-plugin-row-detail')).toBe('dsh-better-sidebar#theme')
|
||||
expect(screen.getByRole('heading', { level: 3 }).textContent).toBe('主题插件')
|
||||
@@ -819,7 +743,6 @@ describe('PluginManagerPage', () => {
|
||||
{ 'plugins.row.config:dsh-better-sidebar#sidebar': view => view === 'page' ? <form aria-label="sidebar settings" /> : null },
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') }))
|
||||
expandComponents(en)
|
||||
const listed = document.querySelector('[data-plugin-row="include:sidebar"]') as HTMLElement
|
||||
expect(within(listed).getByText('Sidebar component')).toBeTruthy()
|
||||
expect(within(listed).getByText('sidebar', { selector: 'code' })).toBeTruthy()
|
||||
@@ -868,7 +791,7 @@ describe('PluginManagerPage', () => {
|
||||
}
|
||||
expect(screen.getByText('sidebar')).toBeTruthy()
|
||||
expect(screen.getByText('@acme/dsh-sidebar-widget')).toBeTruthy()
|
||||
expect(within(document.querySelector('[data-plugin-rows]') as HTMLElement).getByText('侧边导航')).toBeTruthy()
|
||||
expect(screen.getByText('侧边导航')).toBeTruthy()
|
||||
expect(screen.getByText(zh.metadataError.replace('{error}', error))).toBeTruthy()
|
||||
const toggle = screen.getByRole('switch', { name: zh.partToggle.replace('{name}', '导航组件') })
|
||||
expect(toggle).toHaveProperty('disabled', false)
|
||||
@@ -950,7 +873,6 @@ describe('PluginManagerPage', () => {
|
||||
const theme = row({ rowId: 'theme', moduleName: 'dsh-better-sidebar/theme', entryId: 'include:theme' as PluginEntryId })
|
||||
renderTab({ packages: [pkg({ rows: [row(), theme] })] }, { rows: new Set(['dsh-better-sidebar#sidebar']) }, bodies)
|
||||
fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') }))
|
||||
expandComponents(en)
|
||||
expect(screen.queryByRole('button', { name: en.configureRow.replace('{name}', 'dsh-better-sidebar/theme') })).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: en.configureRow.replace('{name}', 'dsh-better-sidebar') }))
|
||||
const page = document.querySelector('[data-plugin-row-detail="dsh-better-sidebar#sidebar"]') as HTMLElement
|
||||
@@ -993,7 +915,6 @@ describe('PluginManagerPage', () => {
|
||||
expect(screen.queryByRole('button', { name: /^act / })).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') }))
|
||||
expandComponents(en)
|
||||
const detail = document.querySelector('[data-plugin-detail]') as HTMLElement
|
||||
const act = within(detail).getByRole('button', { name: 'act bundle dsh-better-sidebar' })
|
||||
expect(within(detail).getByText('badge bundle dsh-better-sidebar')).toBeTruthy()
|
||||
@@ -1027,7 +948,7 @@ describe('PluginManagerPage', () => {
|
||||
})
|
||||
|
||||
it('leaves the version out of a bundle the Host reports none for', () => {
|
||||
const unversioned: PackageView = { name: 'dsh-better-sidebar', installed: true, optional: false, enabled: true, rows: [], overrides: [] }
|
||||
const unversioned: PackageView = { name: 'dsh-better-sidebar', installed: true, optional: false, enabled: true, rows: [] }
|
||||
renderTab({ packages: [unversioned] }, {}, bodies)
|
||||
fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') }))
|
||||
expect(subjects.at(-1)).toEqual({ kind: 'bundle', pkg: { name: 'dsh-better-sidebar', installed: true, enabled: true, rows: [] } })
|
||||
@@ -1039,7 +960,6 @@ describe('PluginManagerPage', () => {
|
||||
const { setLanguage } = renderTab({ packages: [pkg({ name, meta: { description: { en: 'Third-party description.' } } })] })
|
||||
setLanguage(zh)
|
||||
fireEvent.click(screen.getByRole('button', { name: zh.openDetail.replace('{name}', name) }))
|
||||
expandComponents(zh)
|
||||
expect(screen.getByRole('heading', { level: 3 }).textContent).toBe(name)
|
||||
expect(screen.getByText('Third-party description.')).toBeTruthy()
|
||||
expect(document.querySelector('[data-plugin-name]')?.textContent).toBe(name)
|
||||
@@ -1098,7 +1018,6 @@ describe('PluginManagerPage', () => {
|
||||
})],
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') }))
|
||||
expandComponents(en)
|
||||
const detail = document.querySelector('[data-plugin-detail="dsh-better-sidebar"]') as HTMLElement
|
||||
expect(within(detail).getByRole('heading', { level: 3 }).textContent).toBe('dsh-better-sidebar')
|
||||
// The version sits beside the name as a tag; the crumb only leads back.
|
||||
|
||||
@@ -113,15 +113,10 @@ it('hands a custom page the shared configuration form of its entry', () => {
|
||||
})
|
||||
|
||||
describe('packageView', () => {
|
||||
it('retains the Host-reported rows adjusted by the bundle', () => {
|
||||
expect(packageView({ ...BUNDLE, overrides: ['system-prompt', 'tools'] }, PLUGINS).overrides)
|
||||
.toEqual(['system-prompt', 'tools'])
|
||||
})
|
||||
it('joins a bundle with the entries its rows run as', () => {
|
||||
expect(packageView(BUNDLE, PLUGINS)).toEqual({
|
||||
name: 'dsh-better-sidebar', version: '0.16.0', description: 'A sidebar.',
|
||||
installed: true, optional: false, enabled: false,
|
||||
overrides: [],
|
||||
rows: [
|
||||
{ rowId: 'sidebar', moduleName: 'dsh-better-sidebar', entryId: ROW_ENTRY, enabled: true, phase: 'active' },
|
||||
{ rowId: 'theme', moduleName: 'dsh-better-sidebar/theme', enabled: false, phase: null },
|
||||
@@ -137,7 +132,6 @@ describe('packageView', () => {
|
||||
expect(packageView(protectedBundle, PLUGINS)).toEqual({
|
||||
name: '@deepseek-ai/dsh-base', installed: false, optional: false, enabled: true, readOnlyReason: 'management-required',
|
||||
error: { code: 'operation-error', diagnostic: 'broken' },
|
||||
overrides: [],
|
||||
rows: [
|
||||
{ rowId: 'core', moduleName: '@deepseek-ai/dsh-base', entryId: 'include:core', enabled: true, phase: 'active', readOnlyReason: 'management-required' },
|
||||
{ rowId: 'gone', moduleName: 'x', entryId: 'include:gone', enabled: false, phase: null },
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: 405b30feaf48c087
|
||||
/deepseek-ai-dsh-client-ui-schedule/use-this-package:
|
||||
en: b318072ec2191a71
|
||||
zh: adf9a59896141732
|
||||
en: c129ae5d0a150a5d
|
||||
zh: d81976f2840cb059
|
||||
/deepseek-ai-dsh-client-ui-schedule/use-this-package/time-zone-data-source:
|
||||
en: 6bd6882dbbbefcb0
|
||||
zh: 03aa15f1a65d13dc
|
||||
|
||||
@@ -25,8 +25,6 @@ Manage active and inactive tasks from the Automation tasks page and the right Si
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
The enabled Schedule bundle's detail page offers a usage card with its capability, sidebar location, and Open automation tasks action. Explicit enablement also opens dismissible guidance. Both reuse the Host catalog to confirm availability before enabling navigation, show read failures with Retry, and disappear when this plugin unloads. Opening the task page creates no Session or task.
|
||||
|
||||
The shipped Web composition carries no `ui-schedule` row; enable the optional experimental bundle `@deepseek-ai/dsh-experimental-schedule-bundle` from the Plugins page (Official group), or list it in a profile's `dsh.profile.bundles`, to insert and mount `ui-schedule` with the Host Schedule service and the clock context that supplies the model's current time. The Automation tasks sidebar entry opens a global page without selecting or activating a Session. The page header's New action starts a new Session, where the reminder is created by asking the model; the page itself has no creation form. Its task list supports text search and one All, Enabled, and Inactive status filter row; search matches the stored task name, the instruction, and the internal Session id, not a resolved Session title, and the empty state keeps the heading's New action beside the notice. Selecting an active or inactive task opens a second detail level with Rules and Delivery records tabs. Every list row shows the task's stored title with its status, frequency, and next run and never the original Session name; the detail's name control shows the stored title. Rules contains the complete reminder, status, next target for active tasks, original recurrence rule, and task id. Daily rule labels show the saved local time and exact stored IANA zone, preserving nonzero seconds and milliseconds rather than substituting the browser's zone. The Linked session entry in the detail tab strip names the linked Session by its catalog title, falling back to the Session id when no title is available. It opens an available, unarchived Session after metadata loads; pending Session or Workspace metadata, an archived Session, missing Session membership, or a Workspace query error keeps it disabled with an explanation. The click rechecks availability; browsing does not unarchive the Session. Only this action requests Session activation. Deletion is available from the More task actions menu in the detail's top strip, separate from navigation.
|
||||
|
||||
Selecting a task from the list opens Rules; refreshing that task preserves the selected tab. Rules edits an active task's name, instruction, and run time; the edits stay in a local draft until saved, and inactive tasks remain read-only. Unmodified Left/Right arrows cycle the tabs, Home/End select the first/last tab, and keyboard focus follows the selection. The tabs do not consume these keys with Alt, Ctrl, Meta, or Shift. The linked-Session control in the detail tab strip stays available on the Rules tab, and confirmed deletion is available from either view.
|
||||
|
||||
@@ -25,8 +25,6 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
已启用的 Schedule 组合包在详情页提供使用卡片,展示能力、侧栏入口和「打开自动化任务」操作;显式启用后还会显示可关闭的引导。两处均复用 Host 任务目录查询,在确认可用后开放跳转,读取失败时显示重试,并在本插件卸载后移除。打开任务页不会创建 Session 或任务。
|
||||
|
||||
随发行版交付的 Web 组合不含 `ui-schedule` 行;在插件管理页的“官方”分组中启用可选实验性 bundle `@deepseek-ai/dsh-experimental-schedule-bundle`,或把它列进 profile 的 `dsh.profile.bundles`,即可插入 `ui-schedule` 并将它与 Host Schedule 服务、以及为模型提供当前时间的时钟上下文一起挂载。侧栏的自动化任务入口打开全局页面,不选择或激活会话。页面页头的“新建”操作会打开一个新会话,提醒由模型在该会话中创建;页面本身没有创建表单。列表支持文本搜索和一行“全部/已开启/未运行”状态筛选;搜索匹配任务已存储的名称、指令和内部会话 id,不匹配解析出的会话标题,空状态在提示旁保留页头的“新建”操作。选择活动或未运行任务后,进入含“规则”和“任务运行记录”页签的第二层详情。每条列表行显示任务已存储的标题及其状态、频率和下次运行,不显示原会话名称;详情中的名称输入框显示任务已存储的标题。“规则”显示完整提醒、状态、活动任务的下一目标时间、原始重复规则和任务 id。每天的规则标签显示已保存的本地时间与精确的存储 IANA 时区,保留非零秒与毫秒,而不替换为浏览器时区。详情页签条内的“关联会话”入口按会话标题显示,取不到标题时回退显示会话 id。元数据加载后,它可打开可用且未归档的会话;会话或 Workspace 元数据尚未就绪、会话已归档、会话不在列表中或 Workspace 查询失败时,入口保持禁用并显示原因。点击时会重新检查可用性;浏览不会取消会话归档。仅此操作请求激活会话。删除位于详情顶部条带的“更多任务操作”菜单。
|
||||
|
||||
从列表选择任务时默认打开“规则”;刷新同一任务会保留当前页签。活动任务的“规则”修改名称、指令和运行时间;这些修改保留在本地草稿中直到保存,未运行任务保持只读。不带修饰键的左右方向键循环切换页签,Home/End 选择第一个/最后一个页签,键盘焦点随选择移动。页签不会拦截这些键与 Alt、Ctrl、Meta 或 Shift 的组合。共用的会话入口位于详情页签条内,在“规则”页签可见;确认删除操作在两种页签下都可用。
|
||||
|
||||
@@ -62,7 +62,6 @@
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-layout": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-renderer": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-session": "workspace:*",
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
.card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
border: 0.5px solid var(--dsw-alias-border-l3);
|
||||
border-radius: var(--dsw-radius-lg);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card .summary {
|
||||
flex: 1 1 240px;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.location {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 12px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
/** Navigation from the enabled Schedule bundle to its live task page. */
|
||||
import { Button, Modal, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client'
|
||||
import type { ScheduleCatalogEntry } from '@deepseek-ai/dsh-schedule/client'
|
||||
import type { CatalogInjected, CatalogSnapshot } from './catalog-source.ts'
|
||||
import css from './SchedulePluginUsage.module.css'
|
||||
|
||||
/** Shared catalog readiness and navigation supplied by the Schedule plugin. */
|
||||
export interface SchedulePluginUsageInjected extends Pick<CatalogInjected<ScheduleCatalogEntry>, 'hooks' | 'onRetry'> {
|
||||
/** Open the global task page without selecting or creating a Session. */
|
||||
readonly onOpenAutomation: () => void
|
||||
}
|
||||
|
||||
/** Shares for the bundle detail's usage card. */
|
||||
export type SchedulePluginUsageProps = PropsRuntime<'plugins.bundle.usage'>
|
||||
& PropsLocale<'schedule.manager'> & InjectFace<SchedulePluginUsageInjected>
|
||||
|
||||
/** Shares for the guidance shown after explicit bundle enablement. */
|
||||
export type SchedulePluginActivationProps = PropsRuntime<'plugins.bundle.activation'>
|
||||
& PropsLocale<'schedule.manager'> & InjectFace<SchedulePluginUsageInjected>
|
||||
|
||||
type Status = CatalogSnapshot['status']
|
||||
type CopyProps = PropsLocale<'schedule.manager'> & { readonly status: Status }
|
||||
|
||||
function statusKey(status: Status) {
|
||||
return status === 'ready' ? 'plugin.ready' : status === 'loading' ? 'plugin.checking' : 'plugin.unavailable'
|
||||
}
|
||||
|
||||
function UsageSummary({ status, t }: CopyProps) {
|
||||
return <div className={css.summary}>
|
||||
<div className={css.status} role="status">
|
||||
<StateDot state={status === 'ready' ? 'done' : status === 'loading' ? 'ongoing' : 'error'} />
|
||||
<span>{t(statusKey(status))}</span>
|
||||
</div>
|
||||
<p className={css.description}>{t('plugin.capability')}</p>
|
||||
<div className={css.location}>
|
||||
<span>{t('plugin.locationLabel')}</span>
|
||||
<span>{t('plugin.location')}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
/**
|
||||
* Show an enabled bundle's destination once the Host catalog answers.
|
||||
* @param props - bundle enablement, catalog hook, navigation and localized copy.
|
||||
* @returns a usage card, or nothing for a disabled bundle.
|
||||
*/
|
||||
export function SchedulePluginUsage({ pkg, useCatalog, onOpenAutomation, onRetry, t }: SchedulePluginUsageProps) {
|
||||
const status = useCatalog(snapshot => snapshot.status)
|
||||
if (!pkg.enabled) return null
|
||||
return <section className={css.card} aria-label={t('plugin.usageLabel')} data-schedule-plugin-usage>
|
||||
<UsageSummary status={status} t={t} />
|
||||
<div className={css.actions}>
|
||||
{status === 'error' ? <Button variant="outline" size="sm" onClick={() => { void onRetry() }}>{t('list.retry')}</Button> : null}
|
||||
<Button variant="primary" size="sm" disabled={status !== 'ready'} onClick={onOpenAutomation}>{t('plugin.open')}</Button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
/**
|
||||
* Guide an explicit activation to the same task page after the Host answers.
|
||||
* @param props - catalog hook, dismiss and navigation callbacks, and localized copy.
|
||||
* @returns dismissible guidance without creating a task or starting a model turn.
|
||||
*/
|
||||
export function SchedulePluginActivation({ useCatalog, onOpenAutomation, onRetry, onDismiss, t }: SchedulePluginActivationProps) {
|
||||
const status = useCatalog(snapshot => snapshot.status)
|
||||
return <Modal open title={t('plugin.usageLabel')} closeLabel={t('plugin.close')} onClose={onDismiss}
|
||||
footer={<>
|
||||
<Button variant="ghost" onClick={onDismiss}>{t('plugin.close')}</Button>
|
||||
{status === 'error' ? <Button variant="outline" onClick={() => { void onRetry() }}>{t('list.retry')}</Button> : null}
|
||||
<Button variant="primary" disabled={status !== 'ready'} data-modal-autofocus onClick={() => { onDismiss(); onOpenAutomation() }}>{t('plugin.open')}</Button>
|
||||
</>}>
|
||||
<UsageSummary status={status} t={t} />
|
||||
</Modal>
|
||||
}
|
||||
@@ -57,7 +57,6 @@ import { TaskTabBindings } from './task-tab-bindings.ts'
|
||||
import { TaskManagerPage, type TaskManagerInjected } from './TaskManagerPage.tsx'
|
||||
import type { TaskDetailInjected } from './TaskDetail.tsx'
|
||||
import { TaskManagerIcon } from './TaskManagerIcon.tsx'
|
||||
import { SchedulePluginActivation, SchedulePluginUsage, type SchedulePluginUsageInjected } from './SchedulePluginUsage.tsx'
|
||||
import { sessionLinkState } from './session-link.ts'
|
||||
import { en, NS, zh, type ScheduleCatalogKey } from './locales.ts'
|
||||
import { en as managerEn, zh as managerZh, type TaskManagerKey } from './task-manager-locales.ts'
|
||||
@@ -183,19 +182,6 @@ export function apply(ctx: ClientContext): void {
|
||||
locale: MANAGER_NS,
|
||||
label: () => t('panel'),
|
||||
}, TaskManagerIcon))
|
||||
ctx.inject(['layout'], (scope) => {
|
||||
const usage = (): SchedulePluginUsageInjected => ({
|
||||
hooks: manager.hooks,
|
||||
onRetry: manager.onRetry,
|
||||
onOpenAutomation: () => { scope.layout.selectPanel(PANEL_ID) },
|
||||
})
|
||||
scope.slots.inject('plugins.bundle.usage', () => scope.slots.register({
|
||||
name: 'plugins.bundle.usage', key: '@deepseek-ai/dsh-experimental-schedule-bundle', locale: MANAGER_NS, inject: usage,
|
||||
}, SchedulePluginUsage))
|
||||
scope.slots.inject('plugins.bundle.activation', () => scope.slots.register({
|
||||
name: 'plugins.bundle.activation', key: '@deepseek-ai/dsh-experimental-schedule-bundle', locale: MANAGER_NS, inject: usage,
|
||||
}, SchedulePluginActivation))
|
||||
})
|
||||
// The created task is a Turn-level element, not a Tool-group row: the Turn
|
||||
// Definition publishes the settled result and this tail list entry renders
|
||||
// the card beneath the closing prose.
|
||||
|
||||
@@ -5,15 +5,6 @@ import { frequencyEn, frequencyZh } from './frequency-locales.ts'
|
||||
export const en = {
|
||||
'panel': 'Automation tasks',
|
||||
'title': 'Automation tasks',
|
||||
'plugin.usageLabel': 'Use automation tasks',
|
||||
'plugin.ready': 'Automation tasks are ready to use',
|
||||
'plugin.checking': 'Checking automation tasks…',
|
||||
'plugin.unavailable': 'Automation tasks are currently unavailable',
|
||||
'plugin.capability': 'Continue the original session at a scheduled time or on a repeating schedule.',
|
||||
'plugin.locationLabel': 'Location',
|
||||
'plugin.location': 'Sidebar · Automation tasks',
|
||||
'plugin.open': 'Open automation tasks',
|
||||
'plugin.close': 'Close',
|
||||
'new.action': 'New',
|
||||
'list.label': 'Task catalog',
|
||||
'list.nextPrefix': 'Next scheduled time: ',
|
||||
@@ -157,15 +148,6 @@ export type TaskManagerKey = keyof typeof en
|
||||
export const zh: Record<TaskManagerKey, string> = {
|
||||
'panel': '自动化任务',
|
||||
'title': '自动化任务',
|
||||
'plugin.usageLabel': '使用自动化任务',
|
||||
'plugin.ready': '自动化任务已可使用',
|
||||
'plugin.checking': '正在确认自动化任务…',
|
||||
'plugin.unavailable': '自动化任务暂不可用',
|
||||
'plugin.capability': '按时间或周期继续原会话',
|
||||
'plugin.locationLabel': '入口位置',
|
||||
'plugin.location': '侧栏 · 自动化任务',
|
||||
'plugin.open': '打开自动化任务',
|
||||
'plugin.close': '关闭',
|
||||
'new.action': '新建',
|
||||
'list.label': '任务列表',
|
||||
'list.nextPrefix': '下次计划时间:',
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import { UiConversation } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { resolveSlotLabel, type HostObservable, type PropsRenderSlots, type StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { resolveSlotLabel, type HostObservable, type StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
@@ -24,11 +24,9 @@ import { ScheduleCatalogAction } from '../src/client/ScheduleCatalogAction.tsx'
|
||||
import { SessionScheduleHover } from '../src/client/SessionScheduleHover.tsx'
|
||||
import { SessionScheduleMark } from '../src/client/SessionScheduleMark.tsx'
|
||||
import { ScheduleTurnCard, type ScheduleTurnCardInjected } from '../src/client/ScheduleTurnCard.tsx'
|
||||
import { SchedulePluginActivation, SchedulePluginUsage, type SchedulePluginUsageInjected } from '../src/client/SchedulePluginUsage.tsx'
|
||||
import { en, NS, zh } from '../src/client/locales.ts'
|
||||
|
||||
const Empty = () => null
|
||||
const PluginSlots = (_props: PropsRenderSlots<'plugins.bundle.usage' | 'plugins.bundle.activation'>) => null
|
||||
|
||||
/** Raw inject face of an ambient row seat: the shared Host catalog observable. */
|
||||
interface SessionCatalogFace {
|
||||
@@ -139,44 +137,6 @@ function declareTranscriptSeats(ctx: Context): () => void {
|
||||
}
|
||||
|
||||
describe('ui-schedule browser half', () => {
|
||||
it('contributes plugin guidance only while loaded and opens the real task panel without creating a Session', async () => {
|
||||
const catalog = vi.fn().mockResolvedValue({ ok: true, value: [] })
|
||||
const ctx = await baseContext({ schedule: { catalog }, $on: () => () => {} })
|
||||
const selectPanel = vi.fn()
|
||||
const startSession = vi.spyOn(ctx.uiWorkspace, 'startSession')
|
||||
const openSession = vi.spyOn(ctx.uiWorkspace, 'openSession')
|
||||
ctx.provide('layout', { selectPanel } as never)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(ctx.slots.entries('plugins.bundle.usage')).toHaveLength(0)
|
||||
const declaration = ctx.slots.register({ name: 'root', children: {
|
||||
'plugins.bundle.usage': { kind: 'keyed', scope: 'root' },
|
||||
'plugins.bundle.activation': { kind: 'keyed', scope: 'root' },
|
||||
} }, PluginSlots)
|
||||
const usage = ctx.slots.entries('plugins.bundle.usage')[0]!
|
||||
const activation = ctx.slots.entries('plugins.bundle.activation')[0]!
|
||||
expect(usage.options.key).toBe('@deepseek-ai/dsh-experimental-schedule-bundle')
|
||||
expect(usage.component).toBe(SchedulePluginUsage)
|
||||
expect(activation.component).toBe(SchedulePluginActivation)
|
||||
const face = injectedFace(usage) as SchedulePluginUsageInjected
|
||||
const guidance = injectedFace(activation) as SchedulePluginUsageInjected
|
||||
expect(guidance.hooks.catalog).toBe(face.hooks.catalog)
|
||||
const stop = face.hooks.catalog.subscribe(vi.fn())
|
||||
await face.onRetry()
|
||||
expect(face.hooks.catalog.getSnapshot().status).toBe('ready')
|
||||
face.onOpenAutomation()
|
||||
guidance.onOpenAutomation()
|
||||
expect(selectPanel.mock.calls).toEqual([['schedules'], ['schedules']])
|
||||
expect(startSession).not.toHaveBeenCalled()
|
||||
expect(openSession).not.toHaveBeenCalled()
|
||||
stop()
|
||||
await fiber.dispose()
|
||||
expect(ctx.slots.entries('plugins.bundle.usage')).toHaveLength(0)
|
||||
expect(ctx.slots.entries('plugins.bundle.activation')).toHaveLength(0)
|
||||
declaration()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('declares only the services used by registration', () => {
|
||||
expect(inject).toEqual([
|
||||
'slots', 'locale', 'remote', 'remote.schedule', 'conversation', 'uiConversation', 'uiWorkspace', 'sessions',
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { ScheduleCatalogEntry } from '@deepseek-ai/dsh-schedule/client'
|
||||
import type { CatalogSnapshot } from '../src/client/catalog-source.ts'
|
||||
import {
|
||||
SchedulePluginActivation, SchedulePluginUsage,
|
||||
type SchedulePluginActivationProps, type SchedulePluginUsageProps,
|
||||
} from '../src/client/SchedulePluginUsage.tsx'
|
||||
import { en, zh } from '../src/client/task-manager-locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
function props(status: CatalogSnapshot['status'] = 'ready', enabled = true): SchedulePluginUsageProps {
|
||||
const snapshot: CatalogSnapshot<ScheduleCatalogEntry> = {
|
||||
status, records: [], deleting: [], settled: status === 'ready', readRequest: 1,
|
||||
readSettled: status === 'ready' ? 1 : 0,
|
||||
}
|
||||
return {
|
||||
pkg: { name: '@deepseek-ai/dsh-experimental-schedule-bundle', installed: false, enabled, rows: [] },
|
||||
useCatalog: selector => selector(snapshot),
|
||||
onRetry: vi.fn().mockResolvedValue(undefined),
|
||||
onOpenAutomation: vi.fn(),
|
||||
t: makeTranslate(en),
|
||||
} as SchedulePluginUsageProps
|
||||
}
|
||||
|
||||
function activation(status: CatalogSnapshot['status'] = 'ready'): SchedulePluginActivationProps {
|
||||
return {
|
||||
...props(status), packageName: '@deepseek-ai/dsh-experimental-schedule-bundle',
|
||||
onDismiss: vi.fn(), onOpenDetails: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('Schedule plugin usage', () => {
|
||||
it('explains the live capability and opens the task page only on a click', () => {
|
||||
const value = props()
|
||||
render(<SchedulePluginUsage {...value} />)
|
||||
expect(screen.getByText('Automation tasks are ready to use')).toBeTruthy()
|
||||
expect(screen.getByText('Sidebar · Automation tasks')).toBeTruthy()
|
||||
expect(screen.getByText('Continue the original session at a scheduled time or on a repeating schedule.')).toBeTruthy()
|
||||
expect(value.onOpenAutomation).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open automation tasks' }))
|
||||
expect(value.onOpenAutomation).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('hides an explicitly disabled bundle and localizes a ready bundle', () => {
|
||||
const value = props('ready', false)
|
||||
const view = render(<SchedulePluginUsage {...value} />)
|
||||
expect(screen.queryByRole('region')).toBeNull()
|
||||
view.rerender(<SchedulePluginUsage {...props()} t={makeTranslate(zh)} />)
|
||||
expect(screen.getByText('自动化任务已可使用')).toBeTruthy()
|
||||
expect(screen.getByText('侧栏 · 自动化任务')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: '打开自动化任务' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not announce readiness until a catalog query succeeds and retries failures', () => {
|
||||
const loading = props('loading')
|
||||
const view = render(<SchedulePluginUsage {...loading} />)
|
||||
expect(screen.getByText('Checking automation tasks…')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open automation tasks' }))
|
||||
expect(loading.onOpenAutomation).not.toHaveBeenCalled()
|
||||
const failed = props('error')
|
||||
view.rerender(<SchedulePluginUsage {...failed} />)
|
||||
expect(screen.getByText('Automation tasks are currently unavailable')).toBeTruthy()
|
||||
expect(screen.queryByText('Automation tasks are ready to use')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
expect(failed.onRetry).toHaveBeenCalledOnce()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open automation tasks' }))
|
||||
expect(failed.onOpenAutomation).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Schedule activation guidance', () => {
|
||||
it('dismisses the guidance before opening the task page', () => {
|
||||
const value = activation()
|
||||
const calls: string[] = []
|
||||
render(<SchedulePluginActivation {...value} onDismiss={() => { calls.push('close') }} onOpenAutomation={() => { calls.push('open') }} />)
|
||||
expect(screen.getByRole('dialog', { name: 'Use automation tasks' })).toBeTruthy()
|
||||
expect(calls).toEqual([])
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open automation tasks' }))
|
||||
expect(calls).toEqual(['close', 'open'])
|
||||
})
|
||||
|
||||
it('keeps failed activation guidance dismissible with a retry and no ready claim', () => {
|
||||
const value = activation('error')
|
||||
render(<SchedulePluginActivation {...value} />)
|
||||
expect(screen.getByText('Automation tasks are currently unavailable')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
expect(value.onRetry).toHaveBeenCalledOnce()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open automation tasks' }))
|
||||
expect(value.onOpenAutomation).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Close' })[0]!)
|
||||
expect(value.onDismiss).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -29,9 +29,6 @@
|
||||
{
|
||||
"path": "../ui-layout"
|
||||
},
|
||||
{
|
||||
"path": "../ui-plugin-manager/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../ui-sidebar"
|
||||
},
|
||||
|
||||
@@ -1848,7 +1848,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'none',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.add.actions\', () => ctx.slots.register(\n { name: \'plugins.add.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:90',
|
||||
source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:84',
|
||||
},
|
||||
{
|
||||
key: 'plugins.bundle.activation',
|
||||
@@ -1877,17 +1877,16 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
'useSessionRetainInfo: UseSessionRetainInfo',
|
||||
'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
|
||||
],
|
||||
keyDomain: 'open: any string the owner dispatches (no compile-time key set), already taken: @deepseek-ai/dsh-experimental-schedule-bundle, @deepseek-ai/dsh-experimental-voice-input-bundle',
|
||||
keyDomain: 'open: any string the owner dispatches (no compile-time key set), already taken: @deepseek-ai/dsh-experimental-voice-input-bundle',
|
||||
hookContext: '',
|
||||
slotInject: '',
|
||||
declaredBy: 'an entry in \'main\' (client-ui-plugin-manager), so it exists while that entry is mounted',
|
||||
occupants: [
|
||||
'client-ui-schedule SchedulePluginActivation key \'@deepseek-ai/dsh-experimental-schedule-bundle\'',
|
||||
'experimental-client-ui-voice-input VoiceSetupPrompt key \'@deepseek-ai/dsh-experimental-voice-input-bundle\'',
|
||||
],
|
||||
replaceRisk: 'shadows-shipped-ui',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.bundle.activation\', () => ctx.slots.register(\n { name: \'plugins.bundle.activation\', key: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:94',
|
||||
source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:86',
|
||||
},
|
||||
{
|
||||
key: 'plugins.bundle.config',
|
||||
@@ -1927,47 +1926,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'shadows-shipped-ui',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.bundle.config\', () => ctx.slots.register(\n { name: \'plugins.bundle.config\', key: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:110',
|
||||
},
|
||||
{
|
||||
key: 'plugins.bundle.usage',
|
||||
kind: 'keyed',
|
||||
scope: 'root',
|
||||
summary: 'Bundle-owned usage guidance below the description, keyed by npm package name.',
|
||||
doc: 'Bundle-owned usage guidance below the description, keyed by npm package name.',
|
||||
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: [
|
||||
'/** Bundle facts supplied to its usage guidance and entry actions. */\nexport interface PluginBundleUsageProps {\n /** The bundle whose detail page is open. */\n readonly pkg: PluginPackageRef\n}',
|
||||
],
|
||||
ownerPropsReferences: [
|
||||
'PluginPackageRef',
|
||||
],
|
||||
standardProps: [
|
||||
'useResource: UseResource',
|
||||
'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
|
||||
'usePanelInfo: UsePanelInfo',
|
||||
'useSessions: UseSessions',
|
||||
'useSessionStatus: UseSessionStatus',
|
||||
'useSessionRetainInfo: UseSessionRetainInfo',
|
||||
'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
|
||||
],
|
||||
keyDomain: 'open: any string the owner dispatches (no compile-time key set), already taken: @deepseek-ai/dsh-experimental-schedule-bundle',
|
||||
hookContext: '',
|
||||
slotInject: '',
|
||||
declaredBy: 'an entry in \'main\' (client-ui-plugin-manager), so it exists while that entry is mounted',
|
||||
occupants: [
|
||||
'client-ui-schedule SchedulePluginUsage key \'@deepseek-ai/dsh-experimental-schedule-bundle\'',
|
||||
],
|
||||
replaceRisk: 'shadows-shipped-ui',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.bundle.usage\', () => ctx.slots.register(\n { name: \'plugins.bundle.usage\', key: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:92',
|
||||
source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:102',
|
||||
},
|
||||
{
|
||||
key: 'plugins.detail.actions',
|
||||
@@ -2017,7 +1976,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
occupants: [],
|
||||
replaceRisk: 'none',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.detail.actions\', () => ctx.slots.register(\n { name: \'plugins.detail.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:124',
|
||||
source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:116',
|
||||
},
|
||||
{
|
||||
key: 'plugins.detail.badge',
|
||||
@@ -2067,7 +2026,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
occupants: [],
|
||||
replaceRisk: 'none',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.detail.badge\', () => ctx.slots.register(\n { name: \'plugins.detail.badge\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:129',
|
||||
source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:121',
|
||||
},
|
||||
{
|
||||
key: 'plugins.detail.section',
|
||||
@@ -2117,7 +2076,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
occupants: [],
|
||||
replaceRisk: 'none',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.detail.section\', () => ctx.slots.register(\n { name: \'plugins.detail.section\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:136',
|
||||
source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:128',
|
||||
},
|
||||
{
|
||||
key: 'plugins.item',
|
||||
@@ -2172,7 +2131,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'none',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.item\', () => ctx.slots.register(\n { name: \'plugins.item\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:104',
|
||||
source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:96',
|
||||
},
|
||||
{
|
||||
key: 'plugins.row.config',
|
||||
@@ -2210,7 +2169,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
occupants: [],
|
||||
replaceRisk: 'none',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.row.config\', () => ctx.slots.register(\n { name: \'plugins.row.config\', key: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:118',
|
||||
source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:110',
|
||||
},
|
||||
{
|
||||
key: 'rightbar',
|
||||
|
||||
Generated
-3
@@ -4566,9 +4566,6 @@ importers:
|
||||
'@deepseek-ai/dsh-client-ui-layout':
|
||||
specifier: workspace:*
|
||||
version: link:../ui-layout
|
||||
'@deepseek-ai/dsh-client-ui-plugin-manager':
|
||||
specifier: workspace:*
|
||||
version: link:../ui-plugin-manager
|
||||
'@deepseek-ai/dsh-client-ui-primitives':
|
||||
specifier: workspace:*
|
||||
version: link:../ui-primitives
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy": Use the bash tool to
|
||||
- 'button "Mode details: Minimal mode"': Minimal mode
|
||||
- text: Minimal mode
|
||||
- button "More actions"
|
||||
- button "Open right sidebar"
|
||||
- tablist:
|
||||
|
||||
Reference in new issue
Block a user