fix(cua): own query pauses with plugin management operations

This commit is contained in:
creatixchu committed 2026-10-20 20:17:53 +08:00
1 parent 7d8a3fb86f
commit 3509e723a1
20 files changed
+181 -80

No files matched your search

@@ -48,8 +48,8 @@
en: 21a0bbe8eb92229c
zh: 096ff63db61992bb
/deepseek-ai-dsh-client-ui-plugin-manager/understand-the-implementation/configuration-slots:
en: 9e5c9e38cbcad99a
zh: e198341b0bc941e7
en: 954a30f20c8b3d39
zh: e76919d88f19c89a
/deepseek-ai-dsh-client-ui-plugin-manager/further-exploration:
en: f9a42dfed0360d6a
zh: 455a70bcb6c69624
+1 -1
View File
@@ -140,7 +140,7 @@ Custom item pages use the Host entry id as their registration id; row pages use
The page's `main` registration declares `plugins.item`, `plugins.bundle.config`, and `plugins.row.config` as its children, so the slots exist while the page does and a registrant's `ctx.slots.inject` waits for them. `configLedgerSource` projects the three ledgers into one observable — the official items in ledger order with their labels resolved in the active locale, and the bundle and row keys — cached until a ledger or the locale moves; the page binds it as `useConfigLedger` beside the store and never names a configurable plugin itself. The registration-owned navigation store selects the main list, the full experimental plugin list, a bundle, an official plugin, or a bundle row; switching away from the Plugins panel resets it to the list, while React remounts preserve the selected target. A fresh registration starts on the main list. Installation highlighting scrolls to a card only when it is on the current page; it does not change the selected page. Other Client plugins inject `pluginNavigation` and call `ctx.pluginNavigation.openBundle(packageName)` to open bundle details without changing the current Session. The target survives the initial inventory load; an absent bundle displays the list. A registration lives with the browser half that made it. `dsh-client-modules` attaches a package's browser half to the Loader row whose specifier is the bare package name, so every page a bundle registers, for itself or for any of its rows, goes away when that row is switched off; a sub-plugin whose page must outlive the other rows ships as its own package. Cards and detail pages show the Experimental marker only for packages in `EXPERIMENTAL_BUNDLES` in [presentation.ts](src/client/presentation.ts); package-name prefixes do not select the marker.
`plugins.bundle.config` supplies bundle detail configuration, keyed by npm package name, and receives `busy` while the owning bundle is being changed. `plugins.bundle.activation` offers bundle-owned guidance after explicit enablement from the list, with callbacks to dismiss it or open the bundle details. It does not appear merely because an enabled bundle was listed.
`plugins.bundle.config` supplies bundle detail configuration, keyed by npm package name. Client plugins can subscribe to the read-only `ctx.pluginChanges.bundle(packageName)` source to pause their queries while a bundle or one of its rows is being changed. Notifications precede the management request and continue when the page closes; subscribers release their observation on disposal. `plugins.bundle.activation` offers bundle-owned guidance after explicit enablement from the list, with callbacks to dismiss it or open the bundle details. It does not appear merely because an enabled bundle was listed.
</details>
@@ -140,7 +140,7 @@ Host 入口通过生成的 Remote 接口暴露 `pluginRegistryProbe.fastest()`
页面的 `main` 注册把 `plugins.item`、`plugins.bundle.config` 与 `plugins.row.config` 声明为子 slot,因此它们与页面同生,注册方的 `ctx.slots.inject` 会等到它们出现。`configLedgerSource` 把三份账本投影成一个可观察对象——按账本顺序排列、标签按当前语言解析的官方条目,以及组合包与行的键——在账本或语言变化前保持缓存;页面把它作为 `useConfigLedger` 绑在 store 旁边,自身从不点名任何可配置插件。注册拥有的导航 store 选择主列表、完整的实验性插件列表、某个组合包、某个官方插件或组合包的某一行;切换离开插件面板时重置为列表,React 重新挂载则保留所选目标。新的注册从主列表开始。安装后的高亮仅在当前页面有对应卡片时滚动定位,不改变所选页面。其他 Client 插件注入 `pluginNavigation`,调用 `ctx.pluginNavigation.openBundle(packageName)` 即可打开组合包详情,不改变当前 Session。首次读取清单期间保留导航目标;组合包不存在时显示列表。注册与做出它的浏览器半侧同生共死。`dsh-client-modules` 只把一个包的浏览器半侧挂在说明符恰为包名的那一行 Loader 行上,所以组合包为自己或任一行注册的页面,都会在那一行被关闭时一起消失;需要在其他行关闭时仍保留页面的子插件,应作为独立的包发布。 卡片和详情页仅为 [presentation.ts](src/client/presentation.ts) 中 `EXPERIMENTAL_BUNDLES` 名单内的包显示实验性标签,包名前缀不决定标签。
`plugins.bundle.config` 以 npm 包名为 key,提供 Bundle 详情配置,并在所属组合包变更期间接收 `busy`。`plugins.bundle.activation` 在用户从列表显式启用后提供 Bundle 自有引导,并传入关闭引导和打开详情的回调。仅列出已启用的 Bundle 不会触发引导。
`plugins.bundle.config` 以 npm 包名为 key,提供 Bundle 详情配置。Client 插件可订阅只读的 `ctx.pluginChanges.bundle(packageName)`,在组合包或其组件行变更期间暂停查询。通知先于管理请求发出,页面关闭后仍会更新;订阅方在卸载时释放观察。`plugins.bundle.activation` 在用户从列表显式启用后提供 Bundle 自有引导,并传入关闭引导和打开详情的回调。仅列出已启用的 Bundle 不会触发引导。
</details>
@@ -25,7 +25,7 @@ import type { createNavigationStore } from './navigation-store.ts'
import { rowConfigKey, type OfficialItem } from './config-ledger.ts'
import { INSTALL_GIT_EXAMPLE, INSTALL_PATH_EXAMPLE, type PluginManagerLocaleKey } from './locales.ts'
import {
asksMirror, githubRecoveryRegistry, isInstallPending, offeredRegistries, rowKey, updateCandidate,
bundleBusy, asksMirror, githubRecoveryRegistry, isInstallPending, offeredRegistries, rowKey, updateCandidate,
type InstallInputError, type InstallState, type InstallSubject, type PackageRow, type PackageView,
type PluginManagerFace, type PluginManagerState, type RegistryChoice, type VersionCheck,
} from './manager-store.ts'
@@ -801,7 +801,7 @@ function PackageDetail({
{configured
? (
<section className={css.detailSection} data-plugin-config>
{renderSlot('plugins.bundle.config', { view: 'page', busy }, { entryKey: pkg.name })}
{renderSlot('plugins.bundle.config', { view: 'page' }, { entryKey: pkg.name })}
</section>
)
: null}
@@ -1602,8 +1602,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
})
const updates = updatesNote(mine, state.versions, t)
const updatePkg = state.update === null ? undefined : state.packages.find(pkg => pkg.name === state.update?.packageName)
const packageBusy = (pkg: PackageView): boolean => state.busy.includes(pkg.name) || state.update?.packageName === pkg.name
|| (state.install.subject?.name === pkg.name && (isInstallPending(state.install.phase) || state.install.phase === 'checking'))
const packageBusy = (pkg: PackageView): boolean => bundleBusy(state, pkg.name)
const packageCard = (pkg: PackageView): ReactNode => (
<PackageCard
key={pkg.name}
@@ -12,6 +12,7 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis'
// Type-only: the root `main` keyed slot the page registers into, declared by
// ui-layout with the panel id brand, and the `sidebar.panellist` list the
// entry registers into, declared by ui-sidebar.
import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client'
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
@@ -32,6 +33,16 @@ import type {} from './slot-contract.ts'
declare module '@deepseek-ai/cordis' {
interface Context {
/** Read-only management activity, independent of the visible Plugins page. */
pluginChanges: {
/**
* Observe whether a bundle or one of its rows is being changed.
* @param packageName Bundle package name.
* @returns A snapshot source whose subscriptions belong to the caller.
*/
bundle(packageName: string): HostObservable<boolean>
}
/** Cross-plugin navigation to the Plugins panel. */
pluginNavigation: {
/**
@@ -81,6 +92,7 @@ export function apply(ctx: ClientContext): void {
const t = ctx.locale.bind(NS)
const controller = new PluginManagerController(ctx)
ctx.effect(() => () => { controller.dispose() }, 'ui-plugin-manager: controller')
ctx.provide('pluginChanges', { bundle: packageName => controller.observeChanges(packageName) })
// The Host says when what is installed, enabled, or composed changed — from
// this page, the CLI, or another browser — and streams install output.
ctx.effect(() => {
@@ -44,6 +44,19 @@ import { shortName } from './presentation.ts'
import { BundleDetailController, type BundleDetailsState } from './detail-store.ts'
import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client'
/**
* Read whether a bundle or one of its rows has a pending management action.
* @param state Current manager snapshot.
* @param packageName Bundle whose controls and observers must wait.
* @returns Whether the bundle's management operation is still pending.
*/
export function bundleBusy(state: PluginManagerState, packageName: string): boolean {
return state.busy.includes(packageName) || state.update?.packageName === packageName
|| (state.install.subject?.name === packageName && (isInstallPending(state.install.phase) || state.install.phase === 'checking'))
|| (state.packages.find(pkg => pkg.name === packageName)?.rows.some(row =>
row.entryId !== undefined && state.busy.includes(rowKey(row.entryId))) ?? false)
}
/** The action a failed notice names. */
export type FailedAction = 'enable' | 'disable' | 'uninstall' | 'rowEnable' | 'rowDisable'
@@ -671,6 +684,15 @@ export class PluginManagerController {
return this.store.getSnapshot()
}
/**
* Observe existing bundle and row operations independently of the page's lifetime.
* @param packageName Bundle whose management activity is observed.
* @returns A read-only source; unsubscribe releases the caller's observation.
*/
observeChanges(packageName: string): HostObservable<boolean> {
return { getSnapshot: () => bundleBusy(this.getSnapshot(), packageName), subscribe: listener => this.store.subscribe(listener) }
}
/** Stop publishing and drop every late settlement. */
dispose(): void {
this.disposed = true
@@ -26,8 +26,6 @@ import type { ConfigForm, ConfigFormSnapshot } from '@deepseek-ai/dsh-client-ui-
export interface PluginConfigViewProps {
/** `summary` renders the one-liner alone, as text or inline nodes; `page` renders the form with its save control. */
readonly view: 'summary' | 'page'
/** Whether the owning bundle is being changed; supplied on bundle configuration pages. */
readonly busy?: boolean
/** Host-owned configuration values and write actions for this page's entry. */
readonly form?: ConfigPageForm | undefined
}
@@ -124,6 +124,7 @@ describe('ui-plugin-manager browser plugin', () => {
const removeRoot = declare(b.slots)
const mainInjected: object = b.slots.entries('main')[0]!.inject!()
const face = mainInjected as PluginManagerFace
expect(b.ctx.pluginChanges.bundle('unlisted').getSnapshot()).toBe(false)
const overlayInjected: object = b.slots.entries('shell.overlay')[0]!.inject!()
const overlay = overlayInjected as PluginRefreshToastFace
expect(overlay.hooks.pluginManager).toBe(face.hooks.pluginManager)
@@ -2549,19 +2549,19 @@ describe('on-demand Official bundles', () => {
})
})
it('keeps bundle configuration mounted while reporting its pending operation', () => {
it('keeps bundle configuration mounted while its operation is pending', () => {
const name = 'dsh-better-sidebar'
const { set } = renderTab({ packages: [pkg()] }, { bundles: new Set([name]) }, {
[`plugins.bundle.config:${name}`]: (_view, owner) => <div data-testid="bundle-config">{String((owner as { busy?: boolean }).busy)}</div>,
[`plugins.bundle.config:${name}`]: () => <div data-testid="bundle-config">Configuration</div>,
})
fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', name) }))
const panel = screen.getByTestId('bundle-config')
expect(panel.textContent).toBe('false')
expect(panel.textContent).toBe('Configuration')
set({ busy: [name] })
expect(screen.getByTestId('bundle-config')).toBe(panel)
expect(panel.textContent).toBe('true')
expect(panel.textContent).toBe('Configuration')
set({ busy: [] })
expect(panel.textContent).toBe('false')
expect(panel.textContent).toBe('Configuration')
})
describe('registry versions and updates', () => {
@@ -2204,3 +2204,33 @@ describe('version checks and updates', () => {
expect(state().update?.phase).toBe('running')
})
})
it.each(['bundle', 'row'] as const)('publishes %s activity before dispatch and settles it without a mounted page', async (kind) => {
const f = bench()
await f.controller.load()
const activity = f.controller.observeChanges(BUNDLE.name)
const other = f.controller.observeChanges('another-bundle')
const response = Promise.withResolvers<ReturnType<typeof ok<ChangeResult>>>()
onTestFinished(() => { response.resolve(ok(APPLIED)) })
const remote = kind === 'bundle' ? f.plugins.setBundleEnabled : f.plugins.setPluginEnabled
remote.mockImplementationOnce(() => {
expect(activity.getSnapshot()).toBe(true)
expect(other.getSnapshot()).toBe(false)
return response.promise
})
const observations: boolean[] = []
const unsubscribe = activity.subscribe(() => { observations.push(activity.getSnapshot()) })
onTestFinished(unsubscribe)
expect(activity.getSnapshot()).toBe(false)
if (kind === 'bundle') f.face.setEnabled(BUNDLE.name, true)
else f.face.setRowEnabled(ROW_ENTRY, false)
expect(observations).toContain(true)
response.resolve(ok(APPLIED))
await vi.waitFor(() => { expect(activity.getSnapshot()).toBe(false) })
expect(observations.at(-1)).toBe(false)
unsubscribe()
const count = observations.length
await f.controller.load()
expect(observations).toHaveLength(count)
})
+2 -2
View File
@@ -21,8 +21,8 @@
en: be53792edc381ea1
zh: 037407055d9f34a0
/deepseek-ai-dsh-cua/understand-the-implementation:
en: 28bd2554acf59a7e
zh: 884c1514c8481fa6
en: 9c0a00f8bec59f02
zh: 4e38b1cf6f0a4275
/deepseek-ai-dsh-cua/further-exploration:
en: b8b0aece886fadaa
zh: ecd8c88d601f2179
+1 -1
View File
@@ -72,7 +72,7 @@ The provider calls [installCua](src/index.ts) with its prepared helper, connecti
The root Host entry exports `installCua`, `CuaConfig`, and the Typert-discovered `CuaAccess` service; its `apply()` only marks the optional Client contribution. `/client` loads the permission UI through the existing module loader. Separate Host and Client compiler projects keep React, CSS, and browser registrations out of headless imports. `/api` owns the app/window API and its types; `/bridge`, `/native-client`, and `/protocol` retain runtime and platform consumers. `/types` exports only the four named boundary types required by generated Remote declarations. The Client uses package-local types. `/typert` supplies Host reflection and `/remote` supplies generated browser descriptors and codecs; permission authority remains on the Host.
The optional browser contribution mounts the native provider's permission Remote and reuses the Plugins activation and configuration slots. The four setup rows remain visible while unknown fields use placeholders; the system-permission hint reserves one line while queries settle. The default-mode form, runtime configuration, system checks, application decisions, and application icons fill in as each query completes. Application names and actions do not wait for icons; icon positions retain their space. User actions run in order without waiting for pending observation queries. Disposal waits for both actions and queries to settle before releasing the separately owned Remote mount. Query errors stay with the affected section, and activation guidance depends only on system checks. Plugin Manager’s pending state pauses observation during bundle changes and resumes it afterward; late results from the previous activation or after disposal are discarded. The controller consumes the generated `ctx.remote.cuaAccess` namespace directly and handles unsuccessful Remote results in its existing query and action paths. It projects queries into the page and setup dialog; reconnects and updates to the Host-reported settings namespace refresh it. Operation notices live in the shell overlay, so closing the page does not remove them. The provider owns permission checks, persisted decisions, and cancellation; the browser owns no authorization state.
The optional browser contribution mounts the native provider's permission Remote and reuses the Plugins activation and configuration slots. The four setup rows remain visible while unknown fields use placeholders; the system-permission hint reserves one line while queries settle. The default-mode form, runtime configuration, system checks, application decisions, and application icons fill in as each query completes. Application names and actions do not wait for icons; icon positions retain their space. User actions run in order without waiting for pending observation queries. Disposal waits for both actions and queries to settle before releasing the separately owned Remote mount. Query errors stay with the affected section, and activation guidance depends only on system checks. The injection layer observes Plugin Manager operations independently of the page. Bundle and row changes pause all four queries and invalidate in-flight results; settlement refreshes observations even after the page closes. Last known errors remain until a successful read. Disposal discards late results. The controller consumes the generated `ctx.remote.cuaAccess` namespace directly and handles unsuccessful Remote results in its existing query and action paths. It projects queries into the page and setup dialog; reconnects and updates to the Host-reported settings namespace refresh it. Operation notices live in the shell overlay, so closing the page does not remove them. The provider owns permission checks, persisted decisions, and cancellation; the browser owns no authorization state.
The detail page reads the effective default (`selectedDefault ?? default`) from the existing `configForms` service. The default button writes only `selectedDefault` through the form's revision-checked settings path. The execution-mode row uses CUA's `ptcConfiguration()` and `enablePersistentPtc()` controls: the Host edits only `ptc-runtime.executionMode` through the existing configuration editor, preserving its ordinary Loader reload lifecycle. Permission checks and runtime state settle independently. Loading, read-only and unavailable settings cannot submit a change; a failed write remains beside the setting for retry.
</details>
+1 -1
View File
@@ -72,7 +72,7 @@ kind: "package-reference"
根 Host 入口导出 `installCua`、`CuaConfig` 和供 Typert 发现的 `CuaAccess` 服务,`apply()` 只标记可选 Client 贡献。`/client` 通过现有模块加载器加载权限界面。Host 和 Client 使用独立编译项目,headless 导入不会加载 React、CSS 或浏览器注册。`/api` 提供应用、窗口 API 及其类型;`/bridge`、`/native-client` 和 `/protocol` 保留运行时和平台消费者。`/types` 仅导出生成 Remote 声明所需的四个命名类型,Client 使用包内类型。`/typert` 提供 Host 反射,`/remote` 提供浏览器描述和编解码,权限判断仍由 Host 执行。
可选浏览器插件挂载原生 provider 的权限 Remote,复用插件页的启用引导和配置插槽。四排使用条件始终可见,未加载的字段保留占位;系统权限提示在查询期间保留一行高度。默认模式、执行方式、系统权限、应用列表和应用图标分别查询,结果返回后各自显示。应用名称和操作按钮不等待图标,图标位置保留空位。用户操作按顺序执行,不等待尚未完成的查询;卸载时等待操作和查询全部结束,再释放独立持有的 Remote 挂载。查询错误只显示在对应部分,启用引导只等待系统权限结果。插件管理器的忙碌状态让查询在组合包变更期间暂停,结束后恢复;上一次启用期间发起的查询或卸载后的迟到结果会被丢弃。控制器直接接收生成的 `ctx.remote.cuaAccess`,在现有查询和操作流程中处理失败响应,并把查询结果投影到页面和设置弹窗;重新连接或 Host 返回的配置 namespace 更新时刷新状态。操作提示挂载到 shell overlay,关闭页面后仍可显示。provider 负责权限检查、持久授权和取消;浏览器不保存授权决定。
可选浏览器插件挂载原生 provider 的权限 Remote,复用插件页的启用引导和配置插槽。四排使用条件始终可见,未加载的字段保留占位;系统权限提示在查询期间保留一行高度。默认模式、执行方式、系统权限、应用列表和应用图标分别查询,结果返回后各自显示。应用名称和操作按钮不等待图标,图标位置保留空位。用户操作按顺序执行,不等待尚未完成的查询;卸载时等待操作和查询全部结束,再释放独立持有的 Remote 挂载。查询错误只显示在对应部分,启用引导只等待系统权限结果。注入层独立于页面观察插件管理操作。组合包和组件行变更会暂停四项查询,并使进行中的结果失效;操作结束后即使页面已关闭,也会重新查询。已知错误保留至读取成功;卸载后的迟到结果会被丢弃。控制器直接接收生成的 `ctx.remote.cuaAccess`,在现有查询和操作流程中处理失败响应,并把查询结果投影到页面和设置弹窗;重新连接或 Host 返回的配置 namespace 更新时刷新状态。操作提示挂载到 shell overlay,关闭页面后仍可显示。provider 负责权限检查、持久授权和取消;浏览器不保存授权决定。
详情页通过现有 `configForms` 服务读取实际默认值(`selectedDefault ?? default`)。默认模式按钮仅写入 `selectedDefault`,沿用表单带版本检查的设置写入流程。执行方式一行调用 CUA 的 `ptcConfiguration()` 和 `enablePersistentPtc()`:Host 通过现有配置编辑器仅修改 `ptc-runtime.executionMode`,保留普通 Loader 重载规则。权限检查和执行方式状态独立返回。设置仍在加载、只读或不可用时不能提交;写入失败时在设置旁显示错误,供用户重试。
</details>
@@ -16,26 +16,28 @@ export type PermissionProps = InjectFace<PermissionFace> & PropsLocale<typeof NS
export type PermissionSetupProps = PermissionProps
& Pick<PropsRuntime<'plugins.bundle.activation'>, 'onDismiss' | 'onOpenDetails'>
type DefaultPresetProps = PropsLocale<typeof NS> & Pick<PropsRuntime<'plugins.bundle.config'>, 'busy'> & InjectFace<{
type DefaultPresetProps = PermissionProps & InjectFace<{
hooks: { cuaDefaultPreset: ConfigForm<{ default: string; selectedDefault?: string }> }
setPtcDefault: () => Promise<boolean>
}>
type PersistentPtcProps = PermissionProps & Pick<PropsRuntime<'plugins.bundle.config'>, 'busy'> & { setPersistentPtc: () => Promise<boolean> }
type PersistentPtcProps = PermissionProps & { setPersistentPtc: () => Promise<boolean> }
/** The detail page owns explicit preset and execution-mode settings. */
export type PermissionPanelProps = PermissionProps & DefaultPresetProps & PersistentPtcProps
function DefaultPresetSetting(props: DefaultPresetProps) {
const preset = props.useCuaDefaultPreset(value => value)
return <ConfigurationSetting kind="default" status={preset.status} loading={preset.value === undefined && (props.busy === true || preset.status === 'loading')} writable={preset.writable} t={props.t} busy={props.busy === true} save={props.setPtcDefault}
const changing = props.useCuaPermissions(value => value.changing)
return <ConfigurationSetting kind="default" status={preset.status} loading={preset.value === undefined && (changing || preset.status === 'loading')} writable={preset.writable} t={props.t} busy={changing} save={props.setPtcDefault}
configured={(preset.value?.selectedDefault ?? preset.value?.default) === 'ptc'} />
}
function PersistentPtcSetting(props: PersistentPtcProps) {
const runtime = props.useCuaPermissions(value => value.runtime)
const changing = props.useCuaPermissions(value => value.changing)
return <ConfigurationSetting kind="persistent" loading={runtime.value === null && (runtime.busy || runtime.error === null)}
status={runtime.busy ? 'loading' : runtime.error === null && runtime.value !== null ? 'ready' : 'unavailable'} writable={!runtime.busy && runtime.value?.writable === true} t={props.t} busy={props.busy === true} save={props.setPersistentPtc}
status={runtime.busy ? 'loading' : runtime.error === null && runtime.value !== null ? 'ready' : 'unavailable'} writable={!runtime.busy && runtime.value?.writable === true} t={props.t} busy={changing || runtime.busy} save={props.setPersistentPtc}
configured={runtime.value?.persistent === true} />
}
@@ -129,21 +131,20 @@ function PermissionAction({ permission, granted, loading, busy, request, openSet
/** @param props Host observation and explicit user actions. @returns Permission controls without automatic system prompts. */
export function PermissionPanel(props: PermissionPanelProps) {
const { t, refresh, setActive } = props
const { t, refresh } = props
const state = props.useCuaPermissions(value => value)
const observed = state.system.value
const apps = state.applications.value
const transitioning = props.busy === true
const transitioning = state.changing
const busy = transitioning || state.system.busy
const appsBusy = transitioning || state.applications.busy
const loading = observed === null && (busy || state.system.error === null)
const application = !busy && state.system.error !== null ? undefined : observed?.launchApplication
useEffect(() => {
setActive(!transitioning)
if (transitioning) return
refresh()
window.addEventListener('focus', refresh)
return () => { window.removeEventListener('focus', refresh) }
}, [transitioning, refresh, setActive])
}, [refresh])
return <div className={css.panels} data-cua-settings aria-busy={transitioning || state.busy || loading}>
<section className={css.panel} aria-label={t('title')}>
<h3>{t('title')}</h3>
@@ -193,9 +194,9 @@ export function PermissionPanel(props: PermissionPanelProps) {
* @param props Permission observation and activation navigation callbacks.
* @returns Setup guidance when system permissions need attention.
*/
export function PermissionSetup({ t, setActive, useCuaPermissions, onDismiss, onOpenDetails }: PermissionSetupProps) {
export function PermissionSetup({ t, refresh, useCuaPermissions, onDismiss, onOpenDetails }: PermissionSetupProps) {
const state = useCuaPermissions(value => value.system)
useEffect(() => { setActive(true) }, [setActive])
useEffect(() => { refresh() }, [refresh])
const ready = state.value !== null && state.error === null
&& state.value.accessibility && state.value.screenRecording
useEffect(() => { if (ready && !state.busy) onDismiss() }, [ready, state.busy, onDismiss])
@@ -21,13 +21,12 @@ interface Query<T> {
/** Independently settled observations; the overall busy flag follows the latest refresh. */
export type PermissionView = { [K in keyof PermissionData]: Query<PermissionData[K]> } & {
busy: boolean
changing: boolean
toast: { id: number; kind: 'requested' | 'opened' | 'revoked' | 'retryAllowed' } | { id: number; kind: 'failed'; message: string } | null
}
/** Slot actions and the renderer-bound permission projection. */
export interface PermissionFace {
hooks: { cuaPermissions: SnapshotStore<PermissionView> }
/** Pause observation during bundle changes, or resume with a fresh read. */
setActive: (active: boolean) => void
refresh: () => void
request: (permission: CuaSystemPermission) => void
openSettings: (permission: CuaSystemPermission) => void
@@ -36,7 +35,7 @@ export interface PermissionFace {
dismissToast: () => void
}
/** Serializes user actions and drops superseded refresh results or results arriving after disposal. */
/** Serializes actions, pauses reads during management operations, and drops superseded or disposed results. */
export class PermissionController {
/** Last observed permissions and the lifetime of the current refresh/action. */
readonly state = createSnapshotStore<PermissionView>({
@@ -44,12 +43,11 @@ export class PermissionController {
system: { value: null, busy: false, error: null },
applications: { value: null, busy: false, error: null },
icons: { value: null, busy: false, error: null },
busy: false, toast: null,
busy: false, changing: false, toast: null,
})
private tail: Promise<void> = Promise.resolve()
private reads: Promise<void> = Promise.resolve()
private closed = false
private active = true
private epoch = 0
private sequence = 0
/** @param remote Generated CUA Remote namespace; failed results are handled with query and action failures. */
@@ -61,17 +59,6 @@ export class PermissionController {
face(): PermissionFace {
return {
hooks: { cuaPermissions: this.state },
setActive: (active) => {
if (this.closed) return
if (active !== this.active) { this.active = active; this.epoch++ }
if (active) this.enqueue()
else this.state.update((state) => {
state.busy = true
for (const key of ['system', 'applications', 'icons'] as const) {
state[key].busy = true; state[key].error = null
}
})
},
refresh: () => { this.enqueue() },
request: (permission) => { this.enqueue(() => this.remote.requestPermission(permission), 'requested') },
openSettings: (permission) => { this.enqueue(() => this.remote.openSettings(permission), 'opened') },
@@ -80,12 +67,26 @@ export class PermissionController {
dismissToast: () => { if (!this.closed) this.state.update((state) => { state.toast = null }) },
}
}
/**
* Update query admission from the management operation's synchronous notification.
* @param changing Whether a bundle or row operation is pending; settlement refreshes observations.
*/
setChanging(changing: boolean): void {
if (this.closed || changing === this.state.getSnapshot().changing) return
this.epoch++
this.state.update((state) => {
state.changing = changing
state.busy = true
for (const key of ['runtime', 'system', 'applications', 'icons'] as const) state[key].busy = true
})
if (!changing) this.enqueue()
}
/** Prevent publication after UI withdrawal and join the accepted Remote work. */
async dispose(): Promise<void> { this.closed = true; await this.tail; await this.reads }
private enqueue(action?: () => Promise<RemoteResult<unknown>>, kind?: 'requested' | 'opened' | 'revoked' | 'retryAllowed'): void {
if (this.closed || !this.active) return
if (this.closed || this.state.getSnapshot().changing) return
const epoch = this.epoch
const current = () => !this.closed && this.active && epoch === this.epoch
const current = () => !this.closed && epoch === this.epoch
const sequence = ++this.sequence
this.state.update((state) => {
state.busy = true
@@ -34,6 +34,15 @@ export async function mountCuaPermissions(ctx: Context, contribution: TypertRemo
ctx.effect(() => ctx.locale.register(NS, { en, zh }))
const controller = new PermissionController(ctx.remote.cuaAccess)
const face = controller.face()
ctx.inject(['pluginChanges'], (scope) => {
const changes = scope.pluginChanges.bundle('@deepseek-ai/dsh-cua-bundle')
const synchronize = () => { controller.setChanging(changes.getSnapshot()) }
synchronize()
scope.effect(() => {
const unsubscribe = changes.subscribe(synchronize)
return () => { unsubscribe(); controller.setChanging(false) }
})
})
const preset = ctx.configForms.get<{ default: string; selectedDefault?: string }>('agent-preset-registry')
const settings = {
...face,
@@ -124,7 +124,7 @@ it('ignores stale toast completion after the permission UI is disposed', async (
await f.controller.dispose()
const before = f.controller.state.getSnapshot()
f.face.dismissToast()
f.face.setActive(true)
f.controller.setChanging(false)
f.face.refresh()
expect(f.controller.state.getSnapshot()).toBe(before)
})
@@ -214,7 +214,7 @@ it('keeps a user-action failure visible when a refresh is queued behind it', asy
it.each(['success', 'failure'])('drops a permission read %s from the previous bundle activation', async (outcome) => {
const f = fixture()
f.face.setActive(true)
f.face.refresh()
await vi.waitFor(() => { expect(f.controller.state.getSnapshot().busy).toBe(false) })
const pending = Promise.withResolvers<CuaSystemPermissionState>(), entered = Promise.withResolvers<undefined>()
onTestFinished(async () => { pending.resolve(value); await f.controller.dispose() })
@@ -224,8 +224,8 @@ it.each(['success', 'failure'])('drops a permission read %s from the previous bu
})
f.face.refresh()
await entered.promise
f.face.setActive(false)
f.face.setActive(false)
f.controller.setChanging(true)
f.controller.setChanging(true)
expect(f.controller.state.getSnapshot()).toMatchObject({ busy: true, system: { error: null, value } })
f.face.request('accessibility')
expect(f.actions.requestPermission).not.toHaveBeenCalled()
@@ -233,7 +233,7 @@ it.each(['success', 'failure'])('drops a permission read %s from the previous bu
else pending.resolve({ ...value, screenRecording: true })
await Promise.allSettled([pending.promise])
expect(f.controller.state.getSnapshot()).toMatchObject({ busy: true, system: { error: null, value } })
f.face.setActive(true)
f.controller.setChanging(false)
await vi.waitFor(() => { expect(f.controller.state.getSnapshot().busy).toBe(false) })
expect(f.controller.state.getSnapshot()).toMatchObject({ system: { error: null, value } })
expect(f.actions.systemStatus).toHaveBeenCalledTimes(3)
@@ -249,10 +249,10 @@ it.each(['success', 'failure'])('does not publish a user-action %s after its bun
})
f.face.request('accessibility')
await entered.promise
f.face.setActive(false)
f.controller.setChanging(true)
if (outcome === 'failure') pending.reject(new Error('old request cancelled'))
else pending.resolve(undefined)
f.face.setActive(true)
f.controller.setChanging(false)
await vi.waitFor(() => { expect(f.controller.state.getSnapshot().busy).toBe(false) })
expect(f.controller.state.getSnapshot()).toMatchObject({ toast: null, system: { error: null, value } })
expect(f.actions.systemStatus).toHaveBeenCalledOnce()
@@ -302,8 +302,8 @@ it.each(['success', 'failure'])('drops old icon %s after disabling and reenablin
f.actions.applicationIcons.mockImplementationOnce(async () => ({ ok: true, value: await pending.promise }))
f.face.refresh()
await vi.waitFor(() => { expect(f.controller.state.getSnapshot().system.busy).toBe(false) })
f.face.setActive(false)
f.face.setActive(true)
f.controller.setChanging(true)
f.controller.setChanging(false)
if (outcome === 'success') pending.resolve({ '/old/app': 'old-icon' })
else pending.reject(new Error('old helper stopped'))
await vi.waitFor(() => { expect(f.controller.state.getSnapshot().busy).toBe(false) })
@@ -36,6 +36,11 @@ async function fixture(fail = false) {
$on(_event: string, listener: (namespace: string, revision: number) => void) { settingsListener = listener; return unsubscribe }
}
new Remote()
const changes = createSnapshotStore(false)
ctx.provide('pluginChanges', { bundle: (name) => {
expect(name).toBe('@deepseek-ai/dsh-cua-bundle')
return changes
} })
const setDefault = vi.fn(async () => true)
const setPersistent = vi.fn<() => Promise<RemoteResult<void>>>().mockResolvedValue({ ok: true, value: undefined })
const runtimeConfiguration = vi.fn<() => Promise<RemoteResult<{ persistent: boolean; writable: boolean }>>>()
@@ -64,7 +69,7 @@ async function fixture(fail = false) {
'plugins.bundle.config': { kind: 'keyed', scope: 'root' }, 'shell.overlay': { kind: 'list', scope: 'root' },
} } as never, () => null)
if (fail) vi.spyOn(ctx.slots, 'inject').mockImplementationOnce(() => { throw new Error('slot unavailable') })
return { ctx, unmount, unsubscribe, status, systemStatus, applicationIcons,
return { ctx, changes, unmount, unsubscribe, status, systemStatus, applicationIcons,
requestPermission, openSettings, revoke, retryApproval, setDefault, setPersistent, runtimeConfiguration,
updateSettings(namespace: string) { settingsListener!(namespace, 1) },
}
@@ -190,3 +195,25 @@ it('refreshes only the settings namespace reported by the permission owner', asy
await vi.waitFor(() => { expect(f.status).toHaveBeenCalledTimes(2) })
} finally { await f.ctx.fiber.dispose() }
})
it('resumes queries when a management operation settles without a mounted permission page', async () => {
const f = await fixture()
try {
f.changes.set(true)
const fiber = await f.ctx.plugin({ inject: [...inject], apply: ctx => mountCuaPermissions(ctx, contribution) })
const actions = f.ctx.slots.entries('plugins.bundle.config')[0]!.inject!()
actionsOf(actions)
expect(actions.hooks.cuaPermissions.getSnapshot().changing).toBe(true)
actions.refresh()
f.ctx.emit('connection/reset')
expect(f.status).not.toHaveBeenCalled()
f.changes.set(false)
await vi.waitFor(() => { expect(actions.hooks.cuaPermissions.getSnapshot().busy).toBe(false) })
expect(f.status).toHaveBeenCalledOnce()
f.changes.set(true)
await fiber.dispose()
f.changes.set(false)
expect(f.status).toHaveBeenCalledOnce()
} finally { await f.ctx.fiber.dispose() }
})
@@ -24,7 +24,7 @@ function fixture() {
const state = createSnapshotStore<PermissionView>({
runtime: { value: { persistent: false, writable: true }, busy: false, error: null },
system: { value: null, busy: true, error: null }, applications: { value: null, busy: true, error: null },
icons: { value: null, busy: true, error: null }, busy: true, toast: null,
icons: { value: null, busy: true, error: null }, busy: true, changing: false, toast: null,
})
const preset = createSnapshotStore<ConfigFormSnapshot<{ default: string; selectedDefault?: string }>>({
status: 'ready', value: { default: 'standard', selectedDefault: 'ptc' },
@@ -34,7 +34,7 @@ function fixture() {
const props = { useCuaPermissions: bindSnapshotSelector(state), t: makeTranslate(en),
setPersistentPtc: vi.fn(async () => true),
useCuaDefaultPreset: bindSnapshotSelector(preset), setPtcDefault: vi.fn(async () => true),
refresh, setActive: vi.fn((active: boolean) => { if (active) refresh() }), request: vi.fn(), openSettings: vi.fn(), revoke: vi.fn(),
refresh, request: vi.fn(), openSettings: vi.fn(), revoke: vi.fn(),
retryApproval: vi.fn(), dismissToast: vi.fn(),
} satisfies PermissionPanelProps
return { state, preset, props,
@@ -284,22 +284,22 @@ it('keeps all four requirements visible before the first permission response', (
for (const permission of [en.accessibility, en.screenRecording]) expect(within(screen.getByRole('group', { name: permission })).queryByRole('button')).toBeNull()
})
it('keeps content mounted and pauses observation while the bundle changes', () => {
it('keeps content mounted and disables actions while management changes are pending', () => {
const f = fixture()
f.publish({ ...observe(value), system: { value: value.system, busy: false, error: 'provider is reloading' }, busy: false })
const view = render(<PermissionPanel {...f.props} busy />)
f.publish({ changing: true })
const view = render(<PermissionPanel {...f.props} />)
const requirements = screen.getByRole('region', { name: en.title })
expect(within(requirements).getAllByRole('group')).toHaveLength(4)
expect(screen.queryByRole('alert')).toBeNull()
expect(f.props.setActive).toHaveBeenLastCalledWith(false)
expect(screen.getByRole('button', { name: en.request }).hasAttribute('disabled')).toBe(true)
fireEvent.focus(window)
expect(f.props.refresh).not.toHaveBeenCalled()
expect(f.props.refresh).toHaveBeenCalledTimes(2)
f.publish({ system: { ...f.state.getSnapshot().system, busy: true, error: null }, busy: true })
view.rerender(<PermissionPanel {...f.props} busy={false} />)
expect(f.props.setActive).toHaveBeenLastCalledWith(true)
f.publish({ changing: false })
view.rerender(<PermissionPanel {...f.props} />)
expect(screen.getByRole('region', { name: en.title })).toBe(requirements)
expect(f.props.refresh).toHaveBeenCalledOnce()
expect(f.props.refresh).toHaveBeenCalledTimes(2)
f.publish({ system: { ...f.state.getSnapshot().system, busy: false }, busy: false })
expect(screen.getByRole('button', { name: en.request }).hasAttribute('disabled')).toBe(false)
})
@@ -340,7 +340,8 @@ it.each(['applications', 'icons'] as const)('contains a failed %s query in the a
expect(within(access).getByRole('alert').textContent).toContain('offline')
fireEvent.click(within(access).getByRole('button', { name: en.retry }))
expect(f.props.refresh).toHaveBeenCalledTimes(2)
view.rerender(<PermissionPanel {...f.props} busy />)
f.publish({ changing: true })
view.rerender(<PermissionPanel {...f.props} />)
expect(screen.queryByRole('alert')).toBeNull()
})
@@ -2304,7 +2304,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:120',
source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:118',
},
{
key: 'plugins.bundle.activation',
@@ -2345,7 +2345,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.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:128',
source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:126',
},
{
key: 'plugins.bundle.config',
@@ -2362,7 +2362,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
},
],
ownerProps: [
'/** The view the page asks a configuration entry for. */\nexport interface PluginConfigViewProps {\n /** `summary` renders the one-liner alone, as text or inline nodes; `page` renders the form with its save control. */\n readonly view: \'summary\' | \'page\'\n /** Whether the owning bundle is being changed; supplied on bundle configuration pages. */\n readonly busy?: boolean\n /** Host-owned configuration values and write actions for this page\'s entry. */\n readonly form?: ConfigPageForm | undefined\n}',
'/** The view the page asks a configuration entry for. */\nexport interface PluginConfigViewProps {\n /** `summary` renders the one-liner alone, as text or inline nodes; `page` renders the form with its save control. */\n readonly view: \'summary\' | \'page\'\n /** Host-owned configuration values and write actions for this page\'s entry. */\n readonly form?: ConfigPageForm | undefined\n}',
],
ownerPropsReferences: [
'ConfigPageForm',
@@ -2389,7 +2389,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:144',
source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:142',
},
{
key: 'plugins.detail.actions',
@@ -2441,7 +2441,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:158',
source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:156',
},
{
key: 'plugins.detail.badge',
@@ -2493,7 +2493,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:163',
source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:161',
},
{
key: 'plugins.detail.section',
@@ -2545,7 +2545,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:170',
source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:168',
},
{
key: 'plugins.item',
@@ -2574,7 +2574,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
},
],
ownerProps: [
'/** The view the page asks a configuration entry for. */\nexport interface PluginConfigViewProps {\n /** `summary` renders the one-liner alone, as text or inline nodes; `page` renders the form with its save control. */\n readonly view: \'summary\' | \'page\'\n /** Whether the owning bundle is being changed; supplied on bundle configuration pages. */\n readonly busy?: boolean\n /** Host-owned configuration values and write actions for this page\'s entry. */\n readonly form?: ConfigPageForm | undefined\n}',
'/** The view the page asks a configuration entry for. */\nexport interface PluginConfigViewProps {\n /** `summary` renders the one-liner alone, as text or inline nodes; `page` renders the form with its save control. */\n readonly view: \'summary\' | \'page\'\n /** Host-owned configuration values and write actions for this page\'s entry. */\n readonly form?: ConfigPageForm | undefined\n}',
],
ownerPropsReferences: [
'ConfigPageForm',
@@ -2602,7 +2602,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:138',
source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:136',
},
{
key: 'plugins.row.config',
@@ -2619,7 +2619,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
},
],
ownerProps: [
'/** The view the page asks a configuration entry for. */\nexport interface PluginConfigViewProps {\n /** `summary` renders the one-liner alone, as text or inline nodes; `page` renders the form with its save control. */\n readonly view: \'summary\' | \'page\'\n /** Whether the owning bundle is being changed; supplied on bundle configuration pages. */\n readonly busy?: boolean\n /** Host-owned configuration values and write actions for this page\'s entry. */\n readonly form?: ConfigPageForm | undefined\n}',
'/** The view the page asks a configuration entry for. */\nexport interface PluginConfigViewProps {\n /** `summary` renders the one-liner alone, as text or inline nodes; `page` renders the form with its save control. */\n readonly view: \'summary\' | \'page\'\n /** Host-owned configuration values and write actions for this page\'s entry. */\n readonly form?: ConfigPageForm | undefined\n}',
],
ownerPropsReferences: [
'ConfigPageForm',
@@ -2642,7 +2642,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:152',
source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:150',
},
{
key: 'plugins.tab',
@@ -2696,7 +2696,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.tab\', () => ctx.slots.register(\n { name: \'plugins.tab\', 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:126',
source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:124',
},
{
key: 'rightbar',