@stolyarovmn/dsh-ui-registry-aggregator 0.5.1 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +97 -52
  2. package/client.js +360 -19
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,9 +1,20 @@
1
1
  # Registry Aggregator for DeepSeek Harness 0.2.x
2
2
 
3
- This branch is the clean DSH `0.2.0` implementation line. It does **not** carry compatibility code or UI workarounds from DSH `0.1.x`.
3
+ Registry Aggregator adds federated plugin discovery and update discovery around the native DeepSeek Harness Plugin Manager.
4
4
 
5
- Current stable version: `0.5.1`
6
- Current validation baseline: DSH `v0.2.0-rc.2`. Compatibility checks use the actual running DSH version from the active installation manifest rather than a hardcoded RC.
5
+ This is the clean DSH `0.2.0` implementation line. It does **not** carry DSH `0.1.x` compatibility code.
6
+
7
+ Current stable version: `0.5.2`
8
+ Current validation baseline: DSH `v0.2.0-rc.2`
9
+ Declared DSH compatibility: `>=0.2.0-rc.2 <0.3.0`
10
+
11
+ Compatibility badges are evaluated against the DSH version that is actually running rather than a hardcoded RC value.
12
+
13
+ ## Screenshot
14
+
15
+ Real Harness UI, manually verified on DSH `0.2.0-rc.2`:
16
+
17
+ ![Registry Aggregator integrated into the DSH 0.2 Plugin Manager](docs/screenshots/registry-aggregator-v0.5.2.webp)
7
18
 
8
19
  ## Install from npm
9
20
 
@@ -12,62 +23,84 @@ $DSH_VERSION = "0.2.0-rc.2"
12
23
  $PROFILE = "dsh-020-test"
13
24
  $env:DSH_HOME = "$env:USERPROFILE\.dsh-020-test"
14
25
 
15
- pnpm dlx "@deepseek-ai/dsh@$DSH_VERSION" plugin --profile $PROFILE add "@stolyarovmn/dsh-ui-registry-aggregator@0.5.1"
26
+ pnpm dlx "@deepseek-ai/dsh@$DSH_VERSION" plugin --profile $PROFILE add "@stolyarovmn/dsh-ui-registry-aggregator@0.5.2"
16
27
  pnpm dlx "@deepseek-ai/dsh@$DSH_VERSION" --profile $PROFILE
17
28
  ```
18
29
 
19
- The package declares DSH compatibility as `>=0.2.0-rc.2 <0.3.0`. Registry compatibility badges are evaluated against the DSH version that is actually running.
30
+ ## What it adds
31
+
32
+ The native Plugin Manager remains responsible for installation, enable/disable, uninstall, and bundle lifecycle. Registry Aggregator adds discovery, compatibility evidence, metadata, and update orchestration.
33
+
34
+ ### Main Plugins page
35
+
36
+ On DSH `0.2.0-rc.2`, Registry Aggregator extends the main Plugins list with:
20
37
 
21
- ## Current milestone
38
+ - `Installed | Sources | Browse | Updates` navigation using the native Installed heading/count visual language;
39
+ - the original native Installed cards as the **Installed** view;
40
+ - **Sources**, **Browse**, and **Updates** directly on the main Plugins page;
41
+ - a numeric update count beside **Updates**;
42
+ - `Update → vX.Y.Z` directly on an installed package when a newer eligible version is available;
43
+ - **Update all** for eligible updates, processed sequentially with Registry Aggregator updating itself last;
44
+ - automatic teardown of the main-page surface when the Plugin Manager opens a bundle/detail page.
22
45
 
23
- The native Plugin Manager remains responsible for package lifecycle. Registry Aggregator adds discovery capabilities around it.
46
+ The supported `plugins.bundle.config` detail view remains available as a fallback inside **Installed → Registry Aggregator**.
24
47
 
25
- Implemented in this milestone:
48
+ > **DSH 0.2.0-rc.2 integration note:** stock rc.2 does not expose a list-level Plugin Manager slot. The main-page integration is therefore an explicit rc.2 compatibility bridge. It waits until the native Plugin Manager inventory has finished loading, does not use a `MutationObserver`, and removes itself on detail pages. When DSH exposes an appropriate list-level slot, this bridge should be replaced by the supported slot API.
49
+
50
+ ### Sources
26
51
 
27
- - native `plugins.bundle.config` integration;
28
- - `Sources | Browse | Updates` navigation;
29
- - live source configuration through `ctx.configForms.get('registry-aggregator')`; the bundle slot is presentation-only because DSH does not guarantee a single `form` for bundle-wide pages;
30
52
  - built-in npm and GitHub sources;
31
53
  - custom JSON and corporate catalog sources;
32
- - Host-side health checks and discovered-item counts;
33
- - per-source enable/disable, add, remove, refresh, copy-address, count, and error state;
34
- - DSH 0.2-native switch geometry and icon-action sizing copied under the plugin namespace;
35
- - authenticated Host RPC over the DSH Connection service;
36
- - bounded responses, request timeouts, redirect validation, DNS pinning, and private-network blocking;
37
- - no duplicate `Installed` view;
38
- - live federated Browse search across enabled npm, GitHub, custom JSON, and corporate sources;
39
- - compact single-row source/release/freshness/tag/compatibility/page-size filters on desktop, with responsive wrapping below 900px, multi-source metadata, and 20/50/100 pagination; compatibility uses the native DSH shield contour as its neutral filter icon;
40
- - combinable multi-sort criteria for relevance, stars, downloads, freshness, and name with per-criterion direction; when several are active they contribute equally through percentile-normalized composite ranking, so every selected criterion can affect the order;
41
- - npm 30-day download enrichment plus lazy paired `30d | total` stats for visible npm rows; lifetime totals are summed from the package creation date (or npm's 2015-01-10 data floor) in bounded windows, and the UI never presents a partial lifetime sum as a real total;
42
- - package freshness distinguishes npm release time from GitHub repository push time, so GitHub metadata churn does not make every item look newly released;
43
- - best-effort package artwork discovery prefers the official DSH top-level manifest `icon`; when it is absent, Browse can recover the GitHub repository from npm manifest metadata and lazily use a safe repository-local README logo/icon image. Installed Updates reuse the native Plugin Manager `BundleInfo.meta.icon`. All fetched artwork remains limited to SVG/PNG/JPEG/WebP and 256 KiB;
44
- - popular/default Browse results when the query is empty;
45
- - real npm/GitHub source marks and a package icon exposed through the DSH 0.2 manifest contract;
46
- - horizontal layout containment for narrow Plugin Manager detail panes;
47
- - one-click Browse installation through the native DSH `remote.pluginManager` service: `inspect` → `installBundle`, installed-state synchronization via `listBundles` / `plugin-manager/changed`, and inline failure reporting;
48
- - icon-only Browse install states using the same local DSH-style glyph language as the rest of the page;
49
- - explicit `v<version>` package versions plus lazy manifest evidence for GitHub/npm rows;
50
- - compatibility evidence against the actual running DSH version, following DSH peer semantics for `@deepseek-ai/dsh` and `@deepseek-ai/dsh-*`; Browse shows `Compatible`, `Incompatible`, or neutral `Not verified` as plain colored text, and can filter by compatibility;
51
- - live Updates discovery from native installed bundles plus latest npm manifests, with per-package update through `remote.pluginManager.installBundle`; package update actions use a distinct two-arrow Update glyph while content/list refresh keeps the single-arrow Refresh glyph;
52
- - no runtime import of Harness Client implementation packages;
53
- - Harness-provided React and DSH theme tokens.
54
-
55
- Known limitations in `0.5.1`:
56
-
57
- - build-script approval UI remains in the native **Add plugin** dialog;
58
- - bulk **Update all** / cancel orchestration is not part of the DSH 0.2 line yet;
59
- - update discovery for GitHub-only installed dependencies without npm package identity is not implemented yet.
54
+ - enable/disable, add, remove, refresh, copy-address, package/repository count, health, and error state;
55
+ - live configuration through `ctx.configForms.get('registry-aggregator')`;
56
+ - authenticated Host RPC through the DSH Connection service;
57
+ - bounded responses, request timeouts, redirect validation, DNS pinning, and private-network blocking.
58
+
59
+ ### Browse
60
+
61
+ - federated search across enabled npm, GitHub, custom JSON, and corporate sources;
62
+ - popular/default results when the query is empty;
63
+ - compact source, release, freshness, tag, compatibility, and page-size filters;
64
+ - combinable sorting by relevance, stars, downloads, freshness, and name;
65
+ - multi-criterion percentile-normalized composite ranking;
66
+ - 20 / 50 / 100 pagination;
67
+ - explicit package versions;
68
+ - GitHub stars and release/repository freshness;
69
+ - npm `30d | total` download evidence resolved lazily for visible npm rows;
70
+ - package artwork from the DSH manifest icon contract, with safe repository-local README artwork fallback for npm packages that expose GitHub metadata;
71
+ - `Compatible`, `Incompatible`, and `Not verified` evidence against the actual running DSH version;
72
+ - compatibility filtering;
73
+ - icon-only one-click installation through native `remote.pluginManager.inspect()` → `installBundle()`;
74
+ - known-incompatible packages are disabled before installation is attempted.
75
+
76
+ ### Updates
77
+
78
+ - update discovery from native installed bundles plus latest npm manifests;
79
+ - per-package update through the native Plugin Manager;
80
+ - package Update uses a two-arrow glyph while content/list Refresh keeps the single-arrow glyph;
81
+ - native installed artwork is reused when available;
82
+ - sequential **Update all**;
83
+ - explicitly incompatible update targets remain disabled;
84
+ - Registry Aggregator updates itself last so it cannot interrupt the remaining queue.
60
85
 
61
86
  ## Architecture
62
87
 
63
88
  ```text
64
89
  DSH Plugins
65
- └─ Installed
66
- └─ Registry Aggregator
67
- └─ plugins.bundle.config
68
- ├─ Sources
69
- ├─ Browse
70
- └─ Updates
90
+ ├─ Installed | Sources | Browse | Updates
91
+ │ ├─ Installed
92
+ │ │ ├─ native DSH package cards
93
+ │ │ └─ Update → vX.Y.Z
94
+ │ ├─ Sources
95
+ │ ├─ Browse
96
+ │ └─ Updates
97
+ │ └─ Update all
98
+ │
99
+ └─ Installed → Registry Aggregator
100
+ └─ plugins.bundle.config
101
+ ├─ Sources
102
+ ├─ Browse
103
+ └─ Updates
71
104
 
72
105
  Sources UI
73
106
  ├─ ctx.configForms.get('registry-aggregator')
@@ -81,19 +114,33 @@ Sources UI
81
114
  ├─ custom-json
82
115
  └─ corporate
83
116
 
84
- Browse Install
117
+ Plugin lifecycle
85
118
  └─ native DSH remote.pluginManager
86
119
  ├─ inspect(spec)
87
120
  ├─ installBundle(spec)
88
121
  └─ listBundles() / plugin-manager/changed
89
-
90
- Enable / disable / uninstall
91
- └─ native DSH Plugin Manager
92
122
  ```
93
123
 
124
+ ## Known limitations
125
+
126
+ - build-script approval remains in the native **Add plugin** dialog;
127
+ - **Update all** cancellation is not implemented; eligible updates run sequentially once started;
128
+ - automatic update discovery does not yet cover GitHub-only installed dependencies without npm package identity;
129
+ - the main Plugins-list surface is a version-pinned DSH `0.2.0-rc.2` compatibility bridge because rc.2 has no supported list-level Plugin Manager slot.
130
+
131
+ ## Validation
132
+
133
+ `0.5.2` was promoted from the manually verified `0.5.2-rc.6` candidate after:
134
+
135
+ - JavaScript syntax checks;
136
+ - repository unit/client contract tests;
137
+ - `npm pack --dry-run` validation;
138
+ - isolated install smoke test against DSH `0.2.0-rc.2`;
139
+ - real Harness UI verification of the main Plugins-page Browse integration shown above.
140
+
94
141
  ## Test from GitHub
95
142
 
96
- Use a commit SHA from this branch rather than publishing a test package:
143
+ For unreleased development builds, use a commit SHA instead of publishing a test package:
97
144
 
98
145
  ```powershell
99
146
  $DSH_VERSION = "0.2.0-rc.2"
@@ -105,8 +152,6 @@ pnpm dlx "@deepseek-ai/dsh@$DSH_VERSION" plugin --profile $PROFILE add "github:S
105
152
  pnpm dlx "@deepseek-ai/dsh@$DSH_VERSION" --profile $PROFILE
106
153
  ```
107
154
 
108
- Then open **Plugins → Installed → Registry Aggregator → Sources** and verify source state in the real Harness UI.
109
-
110
155
  ## Historical lines
111
156
 
112
157
  - `dsh-0.1.7` — stable `v0.4.17` line for DSH `0.1.7-rc.2`.
package/client.js CHANGED
@@ -86,6 +86,9 @@ window.__ModuleLoader__.load({
86
86
  updateNone: 'Installed plugins are up to date',
87
87
  updateNoneBody: 'No newer package versions were found in the connected registries.',
88
88
  updateRefresh: 'Check for updates',
89
+ updatesAvailable: 'Updates: {count} available',
90
+ updateAll: 'Update all',
91
+ updatingAll: 'Updating all…',
89
92
  downloads30d: 'npm downloads in the last 30 days',
90
93
  downloadsTotal: 'npm total downloads',
91
94
  filterSource: 'Source',
@@ -198,6 +201,9 @@ window.__ModuleLoader__.load({
198
201
  updateNone: '已安装插件均为最新版本',
199
202
  updateNoneBody: '在已连接的注册表中没有发现更高版本。',
200
203
  updateRefresh: '检查更新',
204
+ updatesAvailable: '更新:{count} 个可用',
205
+ updateAll: '全部更新',
206
+ updatingAll: '正在全部更新…',
201
207
  downloads30d: 'npm 最近 30 天下载量',
202
208
  downloadsTotal: 'npm 总下载量',
203
209
  filterSource: '来源',
@@ -241,6 +247,8 @@ window.__ModuleLoader__.load({
241
247
  '.ra-root{box-sizing:border-box;min-width:0;max-width:100%;overflow-x:hidden;color:var(--dsw-alias-label-primary);font-size:13px;line-height:20px;padding:2px 0 8px}',
242
248
  '.ra-tabs{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));width:min(100%,440px);padding:4px;margin:12px 0 24px;border-radius:12px;background:var(--dsw-alias-bg-module-platform,var(--dsw-alias-bg-layer-2))}',
243
249
  '.ra-tab{height:34px;padding:0 14px;border:.5px solid transparent;border-radius:8px;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;cursor:pointer}',
250
+ '.ra-tab-label{display:inline-flex;align-items:center;justify-content:center;gap:6px}',
251
+ '.ra-update-badge{display:inline-flex;align-items:center;justify-content:center;min-width:16px;height:16px;padding:0 4px;border-radius:999px;background:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-label-primary-foreground);font-size:10px;line-height:16px;font-weight:700}',
244
252
  '.ra-tab:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}',
245
253
  '.ra-tab:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:2px}',
246
254
  '.ra-tab[aria-selected=true]{border-color:var(--dsw-alias-border-l3);background:var(--dsw-alias-bg-layer-3,var(--dsw-alias-bg-layer-1));color:var(--dsw-alias-label-primary);font-weight:600}',
@@ -254,6 +262,7 @@ window.__ModuleLoader__.load({
254
262
  '.ra-button:focus-visible,.ra-input:focus-visible,.ra-select:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:1px}',
255
263
  '.ra-button:disabled{opacity:.5;cursor:not-allowed}',
256
264
  '.ra-button-primary{background:var(--dsw-alias-state-business-primary);border-color:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-label-primary-foreground)}',
265
+ '.ra-update-all{display:inline-flex;align-items:center;justify-content:center;gap:6px}',
257
266
  '.ra-panel{border:.5px solid var(--dsw-alias-border-l4);border-radius:12px;background:var(--dsw-alias-bg-layer-1);overflow:hidden}',
258
267
  '.ra-add-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:11px 13px}',
259
268
  '.ra-add-form{display:grid;grid-template-columns:minmax(160px,1fr) minmax(130px,180px) minmax(220px,1.2fr) auto;gap:10px;padding:12px 13px;border-top:.5px solid var(--dsw-alias-border-l4);align-items:end}',
@@ -1678,12 +1687,12 @@ window.__ModuleLoader__.load({
1678
1687
  )
1679
1688
  }
1680
1689
 
1681
- function UpdatesView({ t }) {
1690
+ function useUpdateDiscovery(enabled = true) {
1682
1691
  const [revision, setRevision] = React.useState(0)
1683
- const [state, setState] = React.useState({ loading: true, items: [], error: '' })
1684
- const [operations, setOperations] = React.useState({})
1692
+ const [state, setState] = React.useState({ loading: enabled, items: [], error: '' })
1685
1693
 
1686
1694
  React.useEffect(() => {
1695
+ if (!enabled) return undefined
1687
1696
  let active = true
1688
1697
  const controller = new AbortController()
1689
1698
  const load = async () => {
@@ -1725,36 +1734,76 @@ window.__ModuleLoader__.load({
1725
1734
  controller.abort()
1726
1735
  if (typeof dispose === 'function') dispose()
1727
1736
  }
1728
- }, [revision])
1737
+ }, [enabled, revision])
1738
+
1739
+ const refresh = React.useCallback(() => setRevision(value => value + 1), [])
1740
+ return { state, refresh }
1741
+ }
1729
1742
 
1730
- const runUpdate = async item => {
1743
+ function UpdatesView({ t, discovery }) {
1744
+ const { state, refresh } = discovery
1745
+ const [operations, setOperations] = React.useState({})
1746
+ const [bulkUpdating, setBulkUpdating] = React.useState(false)
1747
+
1748
+ const runUpdate = async (item, refreshAfter = true) => {
1731
1749
  const key = item.bundle.name
1732
1750
  const current = operations[key]
1733
- if (['starting', 'installing', 'applying'].includes(current?.phase)) return
1751
+ if (['starting', 'installing', 'applying'].includes(current?.phase)) return false
1734
1752
  setOperations(value => ({ ...value, [key]: { phase: 'starting', error: '' } }))
1735
1753
  try {
1736
1754
  await updateInstalledPlugin(item.bundle, item.availableVersion, progress => {
1737
1755
  setOperations(value => ({ ...value, [key]: { ...(value[key] ?? {}), ...progress, error: '' } }))
1738
1756
  })
1739
1757
  setOperations(value => ({ ...value, [key]: { phase: 'done', error: '' } }))
1740
- setRevision(value => value + 1)
1758
+ if (refreshAfter) refresh()
1759
+ return true
1741
1760
  } catch (error) {
1742
1761
  setOperations(value => ({ ...value, [key]: { phase: 'failed', error: String(error?.message ?? error) } }))
1762
+ return false
1743
1763
  }
1744
1764
  }
1745
1765
 
1766
+ const runUpdateAll = async () => {
1767
+ if (bulkUpdating || state.loading) return
1768
+ const queue = state.items
1769
+ .filter(item => item.metadata?.compatibility !== 'unsupported')
1770
+ .sort((left, right) => Number(left.bundle.name === PACKAGE) - Number(right.bundle.name === PACKAGE))
1771
+ if (!queue.length) return
1772
+ setBulkUpdating(true)
1773
+ try {
1774
+ for (const item of queue) await runUpdate(item, false)
1775
+ } finally {
1776
+ setBulkUpdating(false)
1777
+ refresh()
1778
+ }
1779
+ }
1780
+
1781
+ const updatableCount = state.items.filter(item => item.metadata?.compatibility !== 'unsupported').length
1782
+
1746
1783
  return h('section', { className: 'ra-section', 'aria-labelledby': 'ra-updates-title' },
1747
1784
  h('div', { className: 'ra-section-head' },
1748
1785
  h('div', null,
1749
1786
  h('h3', { id: 'ra-updates-title', className: 'ra-heading' }, t('updatesTitle')),
1750
1787
  h('p', { className: 'ra-lead' }, t('updatesLead')),
1751
1788
  ),
1752
- h(IconButton, {
1753
- label: t('updateRefresh'),
1754
- disabled: state.loading,
1755
- onClick: () => setRevision(value => value + 1),
1756
- icon: h(IconRefresh, { size: 16, className: state.loading ? 'ra-spin' : undefined }),
1757
- }),
1789
+ h('div', { className: 'ra-actions' },
1790
+ state.items.length > 1 ? h('button', {
1791
+ type: 'button',
1792
+ className: 'ra-button ra-update-all',
1793
+ title: bulkUpdating ? t('updatingAll') : t('updateAll'),
1794
+ disabled: state.loading || bulkUpdating || updatableCount === 0,
1795
+ onClick: () => { void runUpdateAll() },
1796
+ },
1797
+ h(IconUpdate, { size: 14 }),
1798
+ h('span', null, bulkUpdating ? t('updatingAll') : t('updateAll')),
1799
+ ) : null,
1800
+ h(IconButton, {
1801
+ label: t('updateRefresh'),
1802
+ disabled: state.loading || bulkUpdating,
1803
+ onClick: refresh,
1804
+ icon: h(IconRefresh, { size: 16, className: state.loading ? 'ra-spin' : undefined }),
1805
+ }),
1806
+ ),
1758
1807
  ),
1759
1808
  state.loading
1760
1809
  ? h('div', { className: 'ra-plugin-meta', role: 'status' }, t('updateChecking'))
@@ -1799,7 +1848,7 @@ window.__ModuleLoader__.load({
1799
1848
  h(IconButton, {
1800
1849
  label,
1801
1850
  state: done ? 'done' : failed ? 'error' : undefined,
1802
- disabled: busy || done || incompatible,
1851
+ disabled: bulkUpdating || busy || done || incompatible,
1803
1852
  onClick: () => { void runUpdate(item) },
1804
1853
  icon: done
1805
1854
  ? h(IconCheck, { size: 16 })
@@ -1815,6 +1864,7 @@ window.__ModuleLoader__.load({
1815
1864
 
1816
1865
  function RegistryAggregator({ t, view }) {
1817
1866
  const [tab, setTab] = React.useState('sources')
1867
+ const updateDiscovery = useUpdateDiscovery(view === 'page')
1818
1868
  const formState = React.useSyncExternalStore(
1819
1869
  listener => sourceConfigForm.subscribe(listener),
1820
1870
  () => sourceConfigForm.getSnapshot(),
@@ -1825,10 +1875,15 @@ window.__ModuleLoader__.load({
1825
1875
  mutate: (operations, expectedRevision) => sourceConfigForm.mutate(operations, expectedRevision),
1826
1876
  }), [formState])
1827
1877
  if (view !== 'page') return null
1878
+ const updateCount = updateDiscovery.state.items.length
1879
+ const updateLabel = h('span', { className: 'ra-tab-label' },
1880
+ t('updates'),
1881
+ updateCount > 0 ? h('span', { className: 'ra-update-badge', 'aria-hidden': true }, updateCount > 99 ? '99+' : String(updateCount)) : null,
1882
+ )
1828
1883
  const tabs = [
1829
- ['sources', t('sources')],
1830
- ['browse', t('browse')],
1831
- ['updates', t('updates')],
1884
+ ['sources', t('sources'), t('sources')],
1885
+ ['browse', t('browse'), t('browse')],
1886
+ ['updates', updateLabel, updateCount > 0 ? format(t, 'updatesAvailable', { count: updateCount }) : t('updates')],
1832
1887
  ]
1833
1888
  const selectRelative = delta => {
1834
1889
  const index = tabs.findIndex(item => item[0] === tab)
@@ -1838,18 +1893,19 @@ window.__ModuleLoader__.load({
1838
1893
  const body = tab === 'browse'
1839
1894
  ? h(BrowseView, { t })
1840
1895
  : tab === 'updates'
1841
- ? h(UpdatesView, { t })
1896
+ ? h(UpdatesView, { t, discovery: updateDiscovery })
1842
1897
  : h(SourcesView, { t, form })
1843
1898
 
1844
1899
  return h('div', { className: 'ra-root' },
1845
1900
  h('style', null, css),
1846
1901
  h('div', { className: 'ra-tabs', role: 'tablist', 'aria-label': 'Registry Aggregator' },
1847
- ...tabs.map(([id, label]) => h('button', {
1902
+ ...tabs.map(([id, label, ariaLabel]) => h('button', {
1848
1903
  key: id,
1849
1904
  type: 'button',
1850
1905
  role: 'tab',
1851
1906
  className: 'ra-tab',
1852
1907
  'aria-selected': tab === id,
1908
+ 'aria-label': ariaLabel,
1853
1909
  tabIndex: tab === id ? 0 : -1,
1854
1910
  onClick: () => setTab(id),
1855
1911
  onKeyDown: event => {
@@ -1867,6 +1923,290 @@ window.__ModuleLoader__.load({
1867
1923
  )
1868
1924
  }
1869
1925
 
1926
+
1927
+ // Temporary DSH 0.2.0-rc.2 compatibility surface.
1928
+ // Stock rc.2 has no supported list-level Plugin Manager slot, so this bridge
1929
+ // is deliberately version-pinned and mounts only after the native Installed
1930
+ // group exists. It never observes React DOM mutations and removes itself as
1931
+ // soon as the Plugin Manager navigates to a detail page.
1932
+ function PluginsPageBridgeSurface({ t, installed, cards }) {
1933
+ const [tab, setTab] = React.useState('installed')
1934
+ const discovery = useUpdateDiscovery(true)
1935
+ const [operations, setOperations] = React.useState({})
1936
+ const [bulkUpdating, setBulkUpdating] = React.useState(false)
1937
+ const formState = React.useSyncExternalStore(
1938
+ listener => sourceConfigForm.subscribe(listener),
1939
+ () => sourceConfigForm.getSnapshot(),
1940
+ () => sourceConfigForm.getSnapshot(),
1941
+ )
1942
+ const form = React.useMemo(() => ({
1943
+ state: formState,
1944
+ mutate: (operations, expectedRevision) => sourceConfigForm.mutate(operations, expectedRevision),
1945
+ }), [formState])
1946
+
1947
+ const installedCount = installed.querySelector('[data-plugin-count]')?.textContent
1948
+ ?? String(installed.querySelectorAll('[data-plugin-package]').length)
1949
+ const updateCount = discovery.state.items.length
1950
+ const updatableCount = discovery.state.items.filter(item => item.metadata?.compatibility !== 'unsupported').length
1951
+
1952
+ const runUpdate = async (item, refreshAfter = true) => {
1953
+ const key = item.bundle.name
1954
+ const current = operations[key]
1955
+ if (['starting', 'installing', 'applying'].includes(current?.phase)) return false
1956
+ setOperations(value => ({ ...value, [key]: { phase: 'starting', error: '' } }))
1957
+ try {
1958
+ await updateInstalledPlugin(item.bundle, item.availableVersion, progress => {
1959
+ setOperations(value => ({ ...value, [key]: { ...(value[key] ?? {}), ...progress, error: '' } }))
1960
+ })
1961
+ setOperations(value => ({ ...value, [key]: { phase: 'done', error: '' } }))
1962
+ if (refreshAfter) discovery.refresh()
1963
+ return true
1964
+ } catch (error) {
1965
+ setOperations(value => ({ ...value, [key]: { phase: 'failed', error: String(error?.message ?? error) } }))
1966
+ return false
1967
+ }
1968
+ }
1969
+
1970
+ const runUpdateAll = async () => {
1971
+ if (bulkUpdating || discovery.state.loading) return
1972
+ const queue = discovery.state.items
1973
+ .filter(item => item.metadata?.compatibility !== 'unsupported')
1974
+ .sort((left, right) => Number(left.bundle.name === PACKAGE) - Number(right.bundle.name === PACKAGE))
1975
+ if (!queue.length) return
1976
+ setBulkUpdating(true)
1977
+ try {
1978
+ for (const item of queue) await runUpdate(item, false)
1979
+ } finally {
1980
+ setBulkUpdating(false)
1981
+ discovery.refresh()
1982
+ }
1983
+ }
1984
+
1985
+ React.useEffect(() => {
1986
+ cards.style.display = tab === 'installed' ? '' : 'none'
1987
+ return () => { cards.style.display = '' }
1988
+ }, [cards, tab])
1989
+
1990
+ const updateSignature = discovery.state.items
1991
+ .map(item => [item.bundle?.name, item.bundle?.version, item.availableVersion, item.metadata?.compatibility].join(':'))
1992
+ .join('|')
1993
+ const operationSignature = Object.entries(operations)
1994
+ .map(([key, value]) => [key, value?.phase, value?.error].join(':'))
1995
+ .join('|')
1996
+
1997
+ React.useEffect(() => {
1998
+ const updateByName = new Map(discovery.state.items.map(item => [item.bundle?.name, item]))
1999
+ const created = []
2000
+ for (const card of installed.querySelectorAll('[data-plugin-package]')) {
2001
+ const packageName = card.getAttribute('data-plugin-package')
2002
+ const item = updateByName.get(packageName)
2003
+ const old = card.querySelector('[data-ra-installed-update]')
2004
+ if (old) old.remove()
2005
+ if (!item?.availableVersion) continue
2006
+
2007
+ const toggle = card.querySelector('[role="switch"]')
2008
+ const actionHost = toggle?.parentElement
2009
+ if (!actionHost) continue
2010
+
2011
+ const operation = operations[packageName] ?? {}
2012
+ const busy = bulkUpdating || ['starting', 'installing', 'applying'].includes(operation.phase)
2013
+ const incompatible = item.metadata?.compatibility === 'unsupported'
2014
+ const button = document.createElement('button')
2015
+ button.type = 'button'
2016
+ button.className = 'ra-installed-update'
2017
+ button.setAttribute('data-ra-installed-update', '')
2018
+ button.setAttribute('data-state', operation.phase === 'failed' ? 'error' : busy ? 'busy' : 'ready')
2019
+ button.textContent = 'Update → v' + item.availableVersion
2020
+ button.title = operation.error || (incompatible
2021
+ ? format(t, 'incompatibleTitle', { version: item.metadata?.runtimeVersion ?? '?' })
2022
+ : format(t, 'updateAction', { version: 'v' + item.availableVersion }))
2023
+ button.disabled = busy || incompatible
2024
+ button.addEventListener('click', event => {
2025
+ event.preventDefault()
2026
+ event.stopPropagation()
2027
+ void runUpdate(item)
2028
+ })
2029
+ actionHost.insertBefore(button, toggle)
2030
+ created.push(button)
2031
+ }
2032
+ return () => {
2033
+ for (const button of created) button.remove()
2034
+ }
2035
+ }, [installed, updateSignature, operationSignature, bulkUpdating])
2036
+
2037
+ const bridgeTabs = [
2038
+ ['installed', t('installed'), installedCount],
2039
+ ['sources', t('sources'), ''],
2040
+ ['browse', t('browse'), ''],
2041
+ ['updates', t('updates'), updateCount > 0 ? String(updateCount) : ''],
2042
+ ]
2043
+ const selectRelative = delta => {
2044
+ const index = bridgeTabs.findIndex(item => item[0] === tab)
2045
+ setTab(bridgeTabs[(index + delta + bridgeTabs.length) % bridgeTabs.length][0])
2046
+ }
2047
+ const body = tab === 'sources'
2048
+ ? h(SourcesView, { t, form })
2049
+ : tab === 'browse'
2050
+ ? h(BrowseView, { t })
2051
+ : tab === 'updates'
2052
+ ? h(UpdatesView, { t, discovery })
2053
+ : null
2054
+
2055
+ return h('div', { className: 'ra-native-surface' },
2056
+ h('div', { className: 'ra-native-nav-row' },
2057
+ h('div', { className: 'ra-native-tabs', role: 'tablist', 'aria-label': 'Plugin Manager views' },
2058
+ ...bridgeTabs.flatMap(([id, label, count], index) => [
2059
+ index === 0 ? null : h('span', { key: id + '-separator', className: 'ra-native-separator', 'aria-hidden': true }, '|'),
2060
+ h('button', {
2061
+ key: id,
2062
+ type: 'button',
2063
+ role: 'tab',
2064
+ className: 'ra-native-tab',
2065
+ 'aria-selected': tab === id,
2066
+ tabIndex: tab === id ? 0 : -1,
2067
+ onClick: () => setTab(id),
2068
+ onKeyDown: event => {
2069
+ if (event.key === 'ArrowRight') {
2070
+ event.preventDefault()
2071
+ selectRelative(1)
2072
+ } else if (event.key === 'ArrowLeft') {
2073
+ event.preventDefault()
2074
+ selectRelative(-1)
2075
+ }
2076
+ },
2077
+ },
2078
+ h('span', null, label),
2079
+ count ? h('span', { className: 'ra-native-count' }, count) : null,
2080
+ ),
2081
+ ].filter(Boolean)),
2082
+ ),
2083
+ tab === 'installed' && updateCount > 0
2084
+ ? h('button', {
2085
+ type: 'button',
2086
+ className: 'ra-native-update-all',
2087
+ disabled: discovery.state.loading || bulkUpdating || updatableCount === 0,
2088
+ onClick: () => { void runUpdateAll() },
2089
+ },
2090
+ h(IconUpdate, { size: 14 }),
2091
+ h('span', null, bulkUpdating ? t('updatingAll') : t('updateAll')),
2092
+ )
2093
+ : null,
2094
+ ),
2095
+ body ? h('div', { className: 'ra-native-body' }, body) : null,
2096
+ )
2097
+ }
2098
+
2099
+ function installPluginsPagePollingBridge(ctx) {
2100
+ const doc = window.document
2101
+ if (!doc?.querySelector) return () => {}
2102
+
2103
+ const HOST_ATTR = 'data-registry-aggregator-main-surface'
2104
+ const bridgeCss = [
2105
+ '[' + HOST_ATTR + ']{box-sizing:border-box;min-width:0;width:100%}',
2106
+ '[' + HOST_ATTR + '] .ra-native-surface{min-width:0;width:100%}',
2107
+ '[' + HOST_ATTR + '] .ra-native-nav-row{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:22px}',
2108
+ '[' + HOST_ATTR + '] .ra-native-tabs{display:flex;align-items:baseline;min-width:0;flex-wrap:wrap}',
2109
+ '[' + HOST_ATTR + '] .ra-native-tab{display:inline-flex;align-items:baseline;gap:8px;margin:0;padding:0;border:0;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:14px;line-height:22px;font-weight:500;cursor:pointer}',
2110
+ '[' + HOST_ATTR + '] .ra-native-tab[aria-selected=true]{color:var(--dsw-alias-label-primary)}',
2111
+ '[' + HOST_ATTR + '] .ra-native-tab:hover{color:var(--dsw-alias-label-primary)}',
2112
+ '[' + HOST_ATTR + '] .ra-native-tab:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px;border-radius:var(--dsw-radius-xs)}',
2113
+ '[' + HOST_ATTR + '] .ra-native-count{color:var(--dsw-alias-label-caption);font-weight:400;font-variant-numeric:tabular-nums}',
2114
+ '[' + HOST_ATTR + '] .ra-native-separator{padding:0 10px;color:var(--dsw-alias-label-caption);font-size:12px;line-height:22px}',
2115
+ '[' + HOST_ATTR + '] .ra-native-update-all{display:inline-flex;align-items:center;justify-content:center;gap:6px;flex:none;height:30px;padding:0 10px;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-md);background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;line-height:18px;cursor:pointer}',
2116
+ '[' + HOST_ATTR + '] .ra-native-update-all:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}',
2117
+ '[' + HOST_ATTR + '] .ra-native-update-all:disabled{opacity:.5;cursor:default}',
2118
+ '[' + HOST_ATTR + '] .ra-native-body{padding-top:16px}',
2119
+ '.ra-installed-update{position:relative;z-index:2;display:inline-flex;align-items:center;justify-content:center;height:28px;padding:0 9px;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-md);background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;line-height:18px;white-space:nowrap;cursor:pointer}',
2120
+ '.ra-installed-update:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}',
2121
+ '.ra-installed-update:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:1px}',
2122
+ '.ra-installed-update:disabled{opacity:.5;cursor:default}',
2123
+ '.ra-installed-update[data-state=error]{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary) 30%,transparent)}',
2124
+ ].join('')
2125
+ let host
2126
+ let root
2127
+ let createRoot
2128
+ let installedGroup
2129
+ let installedHead
2130
+ let cardsList
2131
+ let headDisplay = ''
2132
+ let cardsDisplay = ''
2133
+ let disposed = false
2134
+
2135
+ const disposeRoot = () => {
2136
+ if (root) {
2137
+ try { root.unmount() } catch {}
2138
+ root = undefined
2139
+ }
2140
+ if (installedHead?.isConnected) installedHead.style.display = headDisplay
2141
+ if (cardsList?.isConnected) cardsList.style.display = cardsDisplay
2142
+ if (installedGroup?.querySelectorAll) {
2143
+ for (const button of installedGroup.querySelectorAll('[data-ra-installed-update]')) button.remove()
2144
+ }
2145
+ if (host?.isConnected) host.remove()
2146
+ host = undefined
2147
+ installedGroup = undefined
2148
+ installedHead = undefined
2149
+ cardsList = undefined
2150
+ }
2151
+
2152
+ const reconcile = () => {
2153
+ if (disposed) return
2154
+ const panel = doc.querySelector('section[data-plugin-panel]')
2155
+ if (!panel) {
2156
+ disposeRoot()
2157
+ return
2158
+ }
2159
+ if (panel.querySelector('[data-plugin-loading]')) return
2160
+
2161
+ const installed = panel.querySelector('section[data-plugin-group="bundles"]')
2162
+ if (!installed) {
2163
+ // Detail pages intentionally have no Installed group. Removing the
2164
+ // bridge here prevents the duplicate UI shown above bundle details.
2165
+ disposeRoot()
2166
+ return
2167
+ }
2168
+ const head = installed.firstElementChild
2169
+ const cards = Array.from(installed.children).find(element => element.tagName === 'UL')
2170
+ if (!head || !cards) return
2171
+ if (host?.isConnected && installedGroup === installed && host.parentElement === installed && head.nextElementSibling === host) return
2172
+
2173
+ disposeRoot()
2174
+ installedGroup = installed
2175
+ installedHead = head
2176
+ cardsList = cards
2177
+ headDisplay = head.style.display
2178
+ cardsDisplay = cards.style.display
2179
+ head.style.display = 'none'
2180
+
2181
+ host = doc.createElement('div')
2182
+ host.setAttribute(HOST_ATTR, '')
2183
+ host.setAttribute('aria-label', 'Registry Aggregator')
2184
+ head.insertAdjacentElement('afterend', host)
2185
+
2186
+ try {
2187
+ if (!createRoot) ({ createRoot } = require('react-dom/client'))
2188
+ if (typeof createRoot !== 'function') throw new Error('react-dom/client createRoot is unavailable')
2189
+ root = createRoot(host)
2190
+ root.render(h(React.Fragment, null,
2191
+ h('style', null, css + '\n' + bridgeCss),
2192
+ h(PluginsPageBridgeSurface, { t: ctx.locale.bind(NS), installed, cards }),
2193
+ ))
2194
+ } catch {
2195
+ disposeRoot()
2196
+ }
2197
+ }
2198
+
2199
+ const timer = window.setInterval(reconcile, 750)
2200
+ const initial = window.setTimeout(reconcile, 900)
2201
+
2202
+ return () => {
2203
+ disposed = true
2204
+ window.clearInterval(timer)
2205
+ window.clearTimeout(initial)
2206
+ disposeRoot()
2207
+ }
2208
+ }
2209
+
1870
2210
  return {
1871
2211
  inject: ['slots', 'locale', 'connection', 'configForms', 'remote', 'remote.pluginManager'],
1872
2212
  apply(ctx) {
@@ -1874,6 +2214,7 @@ window.__ModuleLoader__.load({
1874
2214
  remote = ctx.remote
1875
2215
  sourceConfigForm = ctx.configForms.get(HOST_ENTRY)
1876
2216
  ctx.effect(() => ctx.locale.register(NS, { en, zh }), 'registry-aggregator: locale')
2217
+ ctx.effect(() => ctx.configForms.whileServed([HOST_ENTRY], () => installPluginsPagePollingBridge(ctx)), 'registry-aggregator: DSH 0.2.0-rc.2 Plugins page polling bridge')
1877
2218
  ctx.effect(() => ctx.configForms.whileServed([HOST_ENTRY], () =>
1878
2219
  ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register({
1879
2220
  name: 'plugins.bundle.config',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stolyarovmn/dsh-ui-registry-aggregator",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Federated plugin discovery and update UI for DeepSeek Harness 0.2.x, integrated with the native Plugin Manager.",
5
5
  "author": {
6
6
  "name": "Stolyarovmn",