diff --git a/templates/shared/combolist.tmpl b/templates/shared/combolist.tmpl
new file mode 100644
index 0000000000..c120ca4ae4
--- /dev/null
+++ b/templates/shared/combolist.tmpl
@@ -0,0 +1,46 @@
+{{/*
+ Generic multiselect combo list component.
+ Provides a dropdown with search, checkable items, and a selected-items display list.
+
+ Parameters:
+ Name - form input name (required)
+ Title - display label (required)
+ Items - slice of items, each must have .Value and .Label fields
+ SelectedValues - comma-separated string of selected values
+ Placeholder - search input placeholder (optional, defaults to "Filter...")
+ EmptyText - text when nothing is selected (optional)
+ Disabled - whether the control is disabled (optional)
+ Icon - gear icon shown next to title (optional, defaults to "octicon-gear")
+*/}}
+
+
+
+
+
+ {{or .EmptyText "None"}}
+
+
diff --git a/web_src/css/index.css b/web_src/css/index.css
index 06f7101af9..c09bfd1f7a 100644
--- a/web_src/css/index.css
+++ b/web_src/css/index.css
@@ -34,6 +34,7 @@
@import "./modules/codeeditor.css";
@import "./modules/chroma.css";
@import "./modules/charescape.css";
+@import "./modules/combo-multiselect.css";
@import "./shared/flex-list.css";
@import "./shared/milestone.css";
diff --git a/web_src/css/modules/combo-multiselect.css b/web_src/css/modules/combo-multiselect.css
new file mode 100644
index 0000000000..b92868efa1
--- /dev/null
+++ b/web_src/css/modules/combo-multiselect.css
@@ -0,0 +1,27 @@
+/* Styles for the generic combo-multiselect component (shared/combolist.tmpl) */
+
+.combo-multiselect > .ui.dropdown .item:not(.checked) .item-check-mark {
+ visibility: hidden;
+}
+
+.combo-multiselect > .ui.dropdown .item .item-check-mark {
+ margin-right: 0.5em;
+}
+
+.combo-multiselect > .combo-multiselect-list {
+ margin-top: 0.25em;
+}
+
+.combo-multiselect > .combo-multiselect-list > .item {
+ display: inline-block;
+ padding: 2px 6px;
+ margin: 2px;
+ border-radius: var(--border-radius);
+ background: var(--color-label-bg);
+ font-size: 0.9em;
+}
+
+.combo-multiselect > .combo-multiselect-list > .item.empty-list {
+ background: none;
+ color: var(--color-text-light-2);
+}
diff --git a/web_src/js/features/combo-multiselect.ts b/web_src/js/features/combo-multiselect.ts
new file mode 100644
index 0000000000..a3931a22e3
--- /dev/null
+++ b/web_src/js/features/combo-multiselect.ts
@@ -0,0 +1,93 @@
+// Copyright 2026 Moko Consulting. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+import {fomanticQuery} from '../modules/fomantic/base.ts';
+import {addDelegatedEventListener, queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts';
+
+/**
+ * Generic multiselect combo list component.
+ * Works with the "shared/combolist" template to provide a reusable
+ * dropdown with search, checkable items, and a selected-items display list.
+ *
+ * Usage: add class="combo-multiselect" to the container element.
+ * The component is self-contained — no backend calls, just updates the hidden input.
+ */
+class ComboMultiselect {
+ container: HTMLElement;
+ elDropdown: HTMLElement;
+ elList: HTMLElement;
+ elComboValue: HTMLInputElement;
+
+ constructor(container: HTMLElement) {
+ this.container = container;
+ this.elDropdown = container.querySelector(':scope > .ui.dropdown')!;
+ this.elList = container.querySelector(':scope > .combo-multiselect-list')!;
+ this.elComboValue = container.querySelector(':scope > .combo-value')!;
+ }
+
+ collectCheckedValues(): string[] {
+ return Array.from(
+ this.elDropdown.querySelectorAll('.menu > .item.checked'),
+ (el) => el.getAttribute('data-value')!,
+ );
+ }
+
+ updateUiList() {
+ const checkedValues = this.collectCheckedValues();
+ const elEmptyTip = this.elList.querySelector(':scope > .item.empty-list')!;
+ queryElemChildren(this.elList, '.item:not(.empty-list)', (el) => el.remove());
+
+ for (const value of checkedValues) {
+ const el = this.elDropdown.querySelector(`.menu > .item[data-value="${CSS.escape(value)}"]`);
+ if (!el) continue;
+ const labelText = el.querySelector('.item-label')?.textContent || value;
+ const listItem = document.createElement('span');
+ listItem.classList.add('item');
+ listItem.textContent = labelText;
+ this.elList.append(listItem);
+ }
+
+ toggleElem(elEmptyTip, checkedValues.length === 0);
+ this.elComboValue.value = checkedValues.join(',');
+ this.elComboValue.dispatchEvent(new Event('change', {bubbles: true}));
+ }
+
+ onItemClick(elItem: HTMLElement, e: Event) {
+ e.preventDefault();
+
+ if (elItem.matches('.clear-selection')) {
+ queryElems(this.elDropdown, '.menu > .item', (el) => el.classList.remove('checked'));
+ this.updateUiList();
+ return;
+ }
+
+ elItem.classList.toggle('checked');
+ this.updateUiList();
+ }
+
+ init() {
+ // Restore checked state from initial value
+ const initialValues = this.elComboValue.value ? this.elComboValue.value.split(',') : [];
+ for (const value of initialValues) {
+ const elItem = this.elDropdown.querySelector(`.menu > .item[data-value="${CSS.escape(value)}"]`);
+ elItem?.classList.add('checked');
+ }
+ this.updateUiList();
+
+ addDelegatedEventListener(this.elDropdown, 'click', '.item', (el, e) => this.onItemClick(el, e));
+
+ fomanticQuery(this.elDropdown).dropdown('setting', {
+ action: 'nothing',
+ fullTextSearch: 'exact',
+ hideDividers: 'empty',
+ });
+ }
+}
+
+export function initComboMultiselect() {
+ queryElems(document, '.combo-multiselect', (el) => {
+ if (el.hasAttribute('data-combo-inited')) return;
+ el.setAttribute('data-combo-inited', 'true');
+ new ComboMultiselect(el).init();
+ });
+}
diff --git a/web_src/js/index.ts b/web_src/js/index.ts
index cb2b56a5bd..32195e78e6 100644
--- a/web_src/js/index.ts
+++ b/web_src/js/index.ts
@@ -22,6 +22,7 @@ import {initUserExternalLogins, initUserCheckAppUrl} from './features/user-auth.
import {initRepoPullRequestReview, initRepoIssueFilterItemLabel} from './features/repo-issue.ts';
import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.ts';
import {initRepoTopicBar} from './features/repo-home.ts';
+import {initComboMultiselect} from './features/combo-multiselect.ts';
import {initAdminCommon} from './features/admin/common.ts';
import {initRepoCodeView} from './features/repo-code.ts';
import {initSshKeyFormParser} from './features/sshkey-helper.ts';
@@ -105,6 +106,7 @@ const initPerformanceTracer = callInitFunctions([
initTableSort,
initRepoFileSearch,
initCopyContent,
+ initComboMultiselect,
initAdminCommon,
initAdminUserListSearchForm,