surface marketplace browse tab and polish plugin detail UX

Make marketplace discovery easier to find from the main Plugins screen and tighten the plugin detail layout so metadata and actions stay readable across breakpoints. - add a Browse tab to the main plugin list by reusing the existing marketplace component and matching its store icon - refresh marketplace data after detail/install actions when that tab is active - keep title, installed state, metadata, and tags grouped together while letting detail actions wrap in their own column - add the repository "Give a star" link and move screenshots below the description

Alessandro committed Mar 10, 2026 at 16:24 UTC 976937fae956b6d103c54f926a99d1d372bc410d
4 files changed +221 -157
plugins/plugin_installer/webui/install-detail.html
+61 -36
@@ -23,7 +23,40 @@
23 </div>
24 <div class="pi-hero-info">
25 <div class="pi-hero-title-row">
26 - <h2 class="pi-hero-title" x-text="$store.pluginInstallStore.selectedPlugin.title || $store.pluginInstallStore.selectedPlugin.key"></h2>
26 + <div class="pi-hero-main">
27 + <h2 class="pi-hero-title" x-text="$store.pluginInstallStore.selectedPlugin.title || $store.pluginInstallStore.selectedPlugin.key"></h2>
28 + <template x-if="$store.pluginInstallStore.selectedPlugin.installed">
29 + <div class="pi-installed-badge-simple">
30 + <span class="material-symbols-outlined">check_circle</span>
31 + <span>Installed</span>
32 + </div>
33 + </template>
34 + <div class="pi-hero-meta">
35 + <template x-if="$store.pluginInstallStore.selectedPlugin.author">
36 + <span class="pi-hero-author">
37 + <span class="pi-hero-label">Author</span>
38 + <span x-text="$store.pluginInstallStore.selectedPlugin.author"></span>
39 + </span>
40 + </template>
41 + <span class="pi-hero-stars">
42 + <span class="material-symbols-outlined">star</span>
43 + <span x-text="($store.pluginInstallStore.selectedPlugin.stars || 0)"></span>
44 + <template x-if="$store.pluginInstallStore.selectedPlugin.github">
45 + <a :href="$store.pluginInstallStore.selectedPlugin.github"
46 + target="_blank"
47 + rel="noopener noreferrer"
48 + class="pi-dev-link">
49 + Give a star
50 + </a>
51 + </template>
52 + </span>
53 + </div>
54 + <div class="pi-hero-tags" x-show="$store.pluginInstallStore.selectedPlugin.tags?.length">
55 + <template x-for="tag in $store.pluginInstallStore.selectedPlugin.tags || []" :key="tag">
56 + <span class="pi-tag" x-text="tag"></span>
57 + </template>
58 + </div>
59 + </div>
60 <template x-if="$store.pluginInstallStore.selectedPlugin.installed && $store.pluginInstallStore.installedPluginInfo">
61 <div class="pi-hero-manage">
62 <template x-if="$store.pluginInstallStore.installedPluginInfo.has_main_screen">
@@ -63,32 +96,11 @@
96 </div>
97 </template>
98 </div>
66 - <template x-if="$store.pluginInstallStore.selectedPlugin.installed">
67 - <div class="pi-installed-badge-simple">
68 - <span class="material-symbols-outlined">check_circle</span>
69 - <span>Installed</span>
70 - </div>
71 - </template>
72 - <div class="pi-hero-meta">
73 - <template x-if="$store.pluginInstallStore.selectedPlugin.author">
74 - <span class="pi-hero-author">
75 - <span class="pi-hero-label">Author</span>
76 - <span x-text="$store.pluginInstallStore.selectedPlugin.author"></span>
77 - </span>
78 - </template>
79 - <span class="pi-hero-stars">
80 - <span class="material-symbols-outlined">star</span>
81 - <span x-text="($store.pluginInstallStore.selectedPlugin.stars || 0)"></span>
82 - </span>
83 - </div>
84 - <div class="pi-hero-tags" x-show="$store.pluginInstallStore.selectedPlugin.tags?.length">
85 - <template x-for="tag in $store.pluginInstallStore.selectedPlugin.tags || []" :key="tag">
86 - <span class="pi-tag" x-text="tag"></span>
87 - </template>
88 - </div>
99 </div>
100 </div>
101
102 + <div class="pi-description" x-text="$store.pluginInstallStore.selectedPlugin.description || 'No description available.'"></div>
103 +
104 <div class="pi-screenshots-section"
105 x-show="$store.pluginInstallStore.selectedPlugin.screenshots?.length">
106 <div class="pi-screenshots-header">Screenshots</div>
@@ -102,8 +114,6 @@
114 </div>
115 </div>
116
105 - <div class="pi-description" x-text="$store.pluginInstallStore.selectedPlugin.description || 'No description available.'"></div>
106 -
117 <div class="pi-actions-primary">
118 <template x-if="!$store.pluginInstallStore.selectedPlugin.installed">
119 <button class="pi-btn-install"
@@ -227,19 +237,27 @@
237 }
238
239 .pi-hero-title-row {
230 - display: flex;
240 + display: grid;
241 + grid-template-columns: minmax(0, 1fr) clamp(18rem, 42%, 32rem);
242 align-items: flex-start;
232 - justify-content: space-between;
233 - gap: 0.75rem;
243 + column-gap: 1rem;
244 + row-gap: 0.5rem;
245 margin-bottom: 0.5rem;
246 }
247
248 + .pi-hero-main {
249 + min-width: 0;
250 + width: 100%;
251 + row-gap: var(--spacing-xxs);
252 + }
253 +
254 .pi-hero-title {
255 margin: 0;
256 font-size: 1.75rem;
257 font-weight: 600;
258 line-height: 1.2;
259 color: var(--color-text-primary);
260 + min-width: 0;
261 }
262
263 .pi-hero-manage {
@@ -247,8 +265,9 @@
265 align-items: center;
266 gap: 0.5rem;
267 flex-wrap: wrap;
250 - flex-shrink: 0;
268 justify-content: flex-end;
269 + align-content: flex-start;
270 + width: 100%;
271 }
272
273 .pi-hero-manage .button {
@@ -289,6 +308,7 @@
308 gap: 0.3rem;
309 font-size: 0.95rem;
310 color: var(--color-text-secondary);
311 + flex-wrap: wrap;
312 }
313
314 .pi-hero-stars .material-symbols-outlined {
@@ -309,7 +329,7 @@
329 font-size: 0.9rem;
330 font-weight: 600;
331 color: #22c55e;
312 - margin-bottom: 0.35rem;
332 + margin: var(--spacing-xs) 0;
333 }
334
335 .pi-installed-badge-simple .material-symbols-outlined {
@@ -462,8 +482,8 @@
482 }
483
484 .pi-screenshots-section {
465 - margin-top: 1.5rem;
466 - padding-top: 1.5rem;
485 + margin: var(--spacing-lg) 0;
486 + padding-top: var(--spacing-lg);
487 border-top: 1px solid var(--color-border);
488 }
489
@@ -634,7 +654,7 @@
654 gap: 0.5rem;
655 padding: 0.5rem 0;
656 text-decoration: none;
637 - font-size: 0.9rem;
657 + font-size: 0.8rem;
658 color: var(--color-text-secondary);
659 transition: color 0.15s;
660 }
@@ -673,8 +693,8 @@
693 }
694
695 .pi-hero-title-row {
676 - flex-direction: column;
677 - align-items: center;
696 + grid-template-columns: 1fr;
697 + justify-items: center;
698 }
699
700 .pi-hero-manage {
@@ -685,6 +705,11 @@
705 justify-content: center;
706 }
707
708 + .pi-hero-stars {
709 + flex-basis: 100%;
710 + justify-content: center;
711 + }
712 +
713 .pi-hero-tags {
714 justify-content: center;
715 }
plugins/plugin_installer/webui/pluginInstallStore.js
+4 -1
@@ -569,8 +569,11 @@ const model = {
569 this.selectedPlugin = null;
570 },
571
572 - /** Called from x-destroy when the installer modal is torn down; refreshes the plugin list store */
572 + /** Refresh related list views after installer/detail actions. */
573 refreshPluginList() {
574 + if (pluginListStore.activeTab === "marketplace") {
575 + void this.fetchIndex();
576 + }
577 pluginListStore.refresh();
578 },
579
webui/components/plugins/list/plugin-list.html
+147 -120
@@ -36,130 +36,150 @@
36 Builtin
37 </button>
38 </li>
39 - </ul>
40 -
41 - <div class="plugins-toolbar">
42 - <div class="plugins-toolbar-search">
43 - <input type="text"
44 - class="plugins-search-input"
45 - placeholder="Search plugins by name or description..."
46 - x-model="$store.pluginListStore.searchQuery">
47 - </div>
48 - <div class="plugins-toolbar-actions">
49 - <button type="button"
50 - class="button confirm"
51 - title="Refresh"
52 - @click="$store.pluginListStore.refresh()"
53 - :disabled="$store.pluginListStore.loading">
54 - <span class="icon material-symbols-outlined">refresh</span> Refresh
39 + <li class="nav-item" role="presentation">
40 + <button class="nav-link"
41 + :class="{ active: $store.pluginListStore.activeTab === 'marketplace' }"
42 + id="plugins-marketplace-tab"
43 + type="button"
44 + role="tab"
45 + aria-controls="plugins-marketplace"
46 + :aria-selected="$store.pluginListStore.activeTab === 'marketplace'"
47 + @click="$store.pluginListStore.setTab('marketplace')">
48 + <span class="material-symbols-outlined pi-tab-icon">store</span> Browse
49 </button>
50 + </li>
51 + </ul>
52
57 - <x-extension id="plugins-list-header-buttons"></x-extension>
58 - </div>
59 - </div>
60 -
61 - <div x-show="$store.pluginListStore.loading" class="loading">
62 - <span>Loading plugins...</span>
63 - </div>
64 -
65 - <div class="plugins-list">
66 - <template x-for="plugin in (($store.pluginListStore.plugins || []).filter((plugin) => {
67 - const q = (($store.pluginListStore.searchQuery || '').trim().toLowerCase());
68 - if (!q) return true;
69 - const name = (plugin.display_name || plugin.name || '').toLowerCase();
70 - const description = (plugin.description || '').toLowerCase();
71 - return name.includes(q) || description.includes(q);
72 - }))" :key="plugin.path || plugin.name">
73 - <div class="plugin-card">
74 - <div class="plugin-header">
75 - <div class="plugin-heading">
76 - <div class="plugin-title" x-text="plugin.display_name || plugin.name || '(unnamed plugin)'"></div>
77 - <code class="plugin-path" x-text="plugin.path"></code>
78 - </div>
79 - <div class="plugin-actions">
80 - <template x-if="plugin.has_main_screen">
81 - <button type="button"
82 - class="button"
83 - title="Open"
84 - @click="$store.pluginListStore.openPlugin(plugin)">
85 - <span class="icon material-symbols-outlined">dashboard</span> Open
86 - </button>
87 - </template>
88 - <template x-if="plugin.has_config_screen">
89 - <button type="button"
90 - class="button"
91 - title="Config"
92 - @click="$store.pluginListStore.openPluginConfig(plugin)">
93 - <span class="icon material-symbols-outlined">settings</span> Config
94 - </button>
95 - </template>
96 - <template x-if="plugin.has_readme">
97 - <button type="button"
98 - class="button"
99 - title="README"
100 - @click="$store.pluginListStore.openPluginDoc(plugin, 'readme')">
101 - <span class="icon material-symbols-outlined">description</span> README
102 - </button>
103 - </template>
104 - <template x-if="plugin.has_license">
105 - <button type="button"
106 - class="button"
107 - title="LICENSE"
108 - @click="$store.pluginListStore.openPluginDoc(plugin, 'license')">
109 - <span class="icon material-symbols-outlined">gavel</span> License
110 - </button>
111 - </template>
112 - <template x-if="plugin.has_init_script">
113 - <button type="button"
114 - class="button"
115 - title="Run initializer script"
116 - @click="$store.pluginListStore.openPluginInit(plugin)">
117 - <span class="icon material-symbols-outlined">terminal</span> Init
118 - </button>
119 - </template>
120 - <button type="button"
121 - class="button"
122 - title="Info"
123 - @click="$store.pluginListStore.openPluginInfo(plugin)">
124 - <span class="icon material-symbols-outlined">info</span>
125 - </button>
126 - <template x-if="plugin.is_custom">
127 - <button type="button"
128 - class="button cancel icon-button"
129 - title="Delete"
130 - @click="$confirmClick($event, () => $store.pluginListStore.deletePlugin(plugin))">
131 - <span class="icon material-symbols-outlined">delete</span>
132 - </button>
133 - </template>
134 - </div>
53 + <template x-if="$store.pluginListStore.activeTab !== 'marketplace'">
54 + <div>
55 + <div class="plugins-toolbar">
56 + <div class="plugins-toolbar-search">
57 + <input type="text"
58 + class="plugins-search-input"
59 + placeholder="Search plugins by name or description..."
60 + x-model="$store.pluginListStore.searchQuery">
61 </div>
136 -
137 - <div class="plugin-footer-row">
138 - <div class="plugin-description" x-text="plugin.description || 'No description provided.'"></div>
139 - <div class="plugin-status-group">
140 - <template x-if="plugin.toggle_state === 'advanced'">
141 - <button type="button"
142 - class="button icon-button"
143 - title="Open Advanced"
144 - @click="$store.pluginListStore.openPluginAdvancedToggle(plugin)">
145 - <span class="icon material-symbols-outlined">rule_settings</span>
146 - </button>
147 - </template>
148 - <select class="plugin-status-select"
149 - @change="$store.pluginListStore.updateToggle(plugin, $event.target.value)"
150 - @click.stop
151 - :disabled="plugin.always_enabled">
152 - <option value="enabled" :selected="plugin.toggle_state === 'enabled'">ON</option>
153 - <option value="disabled" :selected="plugin.toggle_state === 'disabled'">OFF</option>
154 - <template x-if="plugin.per_project_config || plugin.per_agent_config">
155 - <option value="advanced" :selected="plugin.toggle_state === 'advanced'">Advanced</option>
156 - </template>
157 - </select>
158 - </div>
62 + <div class="plugins-toolbar-actions">
63 + <button type="button"
64 + class="button confirm"
65 + title="Refresh"
66 + @click="$store.pluginListStore.refresh()"
67 + :disabled="$store.pluginListStore.loading">
68 + <span class="icon material-symbols-outlined">refresh</span> Refresh
69 + </button>
70 +
71 + <x-extension id="plugins-list-header-buttons"></x-extension>
72 </div>
73 </div>
161 - </template>
162 - </div>
74 +
75 + <div x-show="$store.pluginListStore.loading" class="loading">
76 + <span>Loading plugins...</span>
77 + </div>
78 +
79 + <div class="plugins-list">
80 + <template x-for="plugin in (($store.pluginListStore.plugins || []).filter((plugin) => {
81 + const q = (($store.pluginListStore.searchQuery || '').trim().toLowerCase());
82 + if (!q) return true;
83 + const name = (plugin.display_name || plugin.name || '').toLowerCase();
84 + const description = (plugin.description || '').toLowerCase();
85 + return name.includes(q) || description.includes(q);
86 + }))" :key="plugin.path || plugin.name">
87 + <div class="plugin-card">
88 + <div class="plugin-header">
89 + <div class="plugin-heading">
90 + <div class="plugin-title" x-text="plugin.display_name || plugin.name || '(unnamed plugin)'"></div>
91 + <code class="plugin-path" x-text="plugin.path"></code>
92 + </div>
93 + <div class="plugin-actions">
94 + <template x-if="plugin.has_main_screen">
95 + <button type="button"
96 + class="button"
97 + title="Open"
98 + @click="$store.pluginListStore.openPlugin(plugin)">
99 + <span class="icon material-symbols-outlined">dashboard</span> Open
100 + </button>
101 + </template>
102 + <template x-if="plugin.has_config_screen">
103 + <button type="button"
104 + class="button"
105 + title="Config"
106 + @click="$store.pluginListStore.openPluginConfig(plugin)">
107 + <span class="icon material-symbols-outlined">settings</span> Config
108 + </button>
109 + </template>
110 + <template x-if="plugin.has_readme">
111 + <button type="button"
112 + class="button"
113 + title="README"
114 + @click="$store.pluginListStore.openPluginDoc(plugin, 'readme')">
115 + <span class="icon material-symbols-outlined">description</span> README
116 + </button>
117 + </template>
118 + <template x-if="plugin.has_license">
119 + <button type="button"
120 + class="button"
121 + title="LICENSE"
122 + @click="$store.pluginListStore.openPluginDoc(plugin, 'license')">
123 + <span class="icon material-symbols-outlined">gavel</span> License
124 + </button>
125 + </template>
126 + <template x-if="plugin.has_init_script">
127 + <button type="button"
128 + class="button"
129 + title="Run initializer script"
130 + @click="$store.pluginListStore.openPluginInit(plugin)">
131 + <span class="icon material-symbols-outlined">terminal</span> Init
132 + </button>
133 + </template>
134 + <button type="button"
135 + class="button"
136 + title="Info"
137 + @click="$store.pluginListStore.openPluginInfo(plugin)">
138 + <span class="icon material-symbols-outlined">info</span>
139 + </button>
140 + <template x-if="plugin.is_custom">
141 + <button type="button"
142 + class="button cancel icon-button"
143 + title="Delete"
144 + @click="$confirmClick($event, () => $store.pluginListStore.deletePlugin(plugin))">
145 + <span class="icon material-symbols-outlined">delete</span>
146 + </button>
147 + </template>
148 + </div>
149 + </div>
150 +
151 + <div class="plugin-footer-row">
152 + <div class="plugin-description" x-text="plugin.description || 'No description provided.'"></div>
153 + <div class="plugin-status-group">
154 + <template x-if="plugin.toggle_state === 'advanced'">
155 + <button type="button"
156 + class="button icon-button"
157 + title="Open Advanced"
158 + @click="$store.pluginListStore.openPluginAdvancedToggle(plugin)">
159 + <span class="icon material-symbols-outlined">rule_settings</span>
160 + </button>
161 + </template>
162 + <select class="plugin-status-select"
163 + @change="$store.pluginListStore.updateToggle(plugin, $event.target.value)"
164 + @click.stop
165 + :disabled="plugin.always_enabled">
166 + <option value="enabled" :selected="plugin.toggle_state === 'enabled'">ON</option>
167 + <option value="disabled" :selected="plugin.toggle_state === 'disabled'">OFF</option>
168 + <template x-if="plugin.per_project_config || plugin.per_agent_config">
169 + <option value="advanced" :selected="plugin.toggle_state === 'advanced'">Advanced</option>
170 + </template>
171 + </select>
172 + </div>
173 + </div>
174 + </div>
175 + </template>
176 + </div>
177 + </div>
178 + </template>
179 +
180 + <template x-if="$store.pluginListStore.activeTab === 'marketplace'">
181 + <x-component path="/plugins/plugin_installer/webui/install-index.html"></x-component>
182 + </template>
183 </div>
184 </template>
185 </div>
@@ -168,7 +188,7 @@
188 .nav {
189 display: flex;
190 padding-left: 0;
171 - margin: 0.75rem 0 0 0;
191 + margin: 0 0 1rem 0;
192 list-style: none;
193 border-bottom: 1px solid var(--color-border);
194 gap: 0.25rem;
@@ -183,6 +203,9 @@
203 background: transparent;
204 color: var(--color-text-secondary);
205 cursor: pointer;
206 + display: inline-flex;
207 + align-items: center;
208 + gap: 0.3rem;
209 }
210
211 .nav-link.active {
@@ -192,6 +215,10 @@
215 background: var(--color-background);
216 }
217
218 + .pi-tab-icon {
219 + font-size: 1.1rem;
220 + }
221 +
222 .plugins-toolbar {
223 display: flex;
224 align-items: center;
webui/components/plugins/list/pluginListStore.js
+9
@@ -39,6 +39,12 @@ const model = {
39 },
40
41 async setTab(tab) {
42 + if (tab === "marketplace") {
43 + this.activeTab = "marketplace";
44 + this.loading = false;
45 + return;
46 + }
47 +
48 this.activeTab = tab === "builtin" ? "builtin" : "custom";
49 const filter =
50 this.activeTab === "builtin"
@@ -48,6 +54,9 @@ const model = {
54 },
55
56 async refresh() {
57 + if (this.activeTab === "marketplace") {
58 + return;
59 + }
60 await this.setTab(this.activeTab);
61 },
62