diff --git a/py/api/admin.py b/py/api/admin.py
index 1af2136..5727c30 100644
--- a/py/api/admin.py
+++ b/py/api/admin.py
@@ -713,6 +713,7 @@ class AdminRoutesMixin:
"settings": {
"result_timeout": PromptManagerConfig.RESULT_TIMEOUT,
"webui_display_mode": PromptManagerConfig.WEBUI_DISPLAY_MODE,
+ "infinite_scroll": PromptManagerConfig.INFINITE_SCROLL,
"worker_threads": PromptManagerConfig.WORKER_THREADS,
"cpu_count": PromptManagerConfig.max_worker_threads(),
"gallery_root_paths": root_paths,
@@ -759,6 +760,16 @@ class AdminRoutesMixin:
)
if "webui_display_mode" in data:
PromptManagerConfig.WEBUI_DISPLAY_MODE = data["webui_display_mode"]
+ if "infinite_scroll" in data:
+ if not isinstance(data["infinite_scroll"], bool):
+ return web.json_response(
+ {
+ "success": False,
+ "error": "infinite_scroll must be true or false",
+ },
+ status=400,
+ )
+ PromptManagerConfig.INFINITE_SCROLL = data["infinite_scroll"]
if "worker_threads" in data:
try:
PromptManagerConfig.WORKER_THREADS = validate_worker_threads(
@@ -857,6 +868,7 @@ class AdminRoutesMixin:
web_ui = dict(existing.get("web_ui") or {})
web_ui["result_timeout"] = PromptManagerConfig.RESULT_TIMEOUT
web_ui["webui_display_mode"] = PromptManagerConfig.WEBUI_DISPLAY_MODE
+ web_ui["infinite_scroll"] = PromptManagerConfig.INFINITE_SCROLL
gallery = dict(existing.get("gallery") or {})
monitoring = dict(gallery.get("monitoring") or {})
monitoring["directories"] = list(GalleryConfig.MONITORING_DIRECTORIES)
diff --git a/py/config.py b/py/config.py
index bf43b5d..cc89108 100644
--- a/py/config.py
+++ b/py/config.py
@@ -499,6 +499,7 @@ class PromptManagerConfig:
RESULT_TIMEOUT = 5 # Seconds to auto-hide results in ComfyUI node
SHOW_TEST_BUTTON = False # Show API test button in node UI
WEBUI_DISPLAY_MODE = "newtab" # 'popup' or 'newtab'
+ INFINITE_SCROLL = False # Dashboard list: load the next page on scroll by default
# Performance settings
MAX_SEARCH_RESULTS = 100
@@ -555,6 +556,7 @@ class PromptManagerConfig:
"result_timeout": cls.RESULT_TIMEOUT,
"show_test_button": cls.SHOW_TEST_BUTTON,
"webui_display_mode": cls.WEBUI_DISPLAY_MODE,
+ "infinite_scroll": cls.INFINITE_SCROLL,
},
"performance": {
"max_search_results": cls.MAX_SEARCH_RESULTS,
@@ -681,6 +683,8 @@ class PromptManagerConfig:
cls.SHOW_TEST_BUTTON = web_ui["show_test_button"]
if "webui_display_mode" in web_ui:
cls.WEBUI_DISPLAY_MODE = web_ui["webui_display_mode"]
+ if isinstance(web_ui.get("infinite_scroll"), bool):
+ cls.INFINITE_SCROLL = web_ui["infinite_scroll"]
performance = new_config.get("performance", {})
if "max_search_results" in performance:
diff --git a/tests/test_api_admin.py b/tests/test_api_admin.py
index 180d09c..340ea53 100644
--- a/tests/test_api_admin.py
+++ b/tests/test_api_admin.py
@@ -928,6 +928,29 @@ class TestDuplicateJobsAreSingleFlight(AdminAPITestCase):
class TestSettingsMisc(AdminAPITestCase):
+ async def test_infinite_scroll_default_is_reported_saved_and_validated(self):
+ data = await (
+ await self.client.request("GET", "/prompt_manager/settings")
+ ).json()
+ self.assertIs(data["settings"]["infinite_scroll"], False)
+
+ resp = await self.client.request(
+ "POST", "/prompt_manager/settings", json={"infinite_scroll": True}
+ )
+ self.assertEqual(resp.status, 200)
+ self.assertTrue(PromptManagerConfig.INFINITE_SCROLL)
+ self.assertIs(self._read_config()["web_ui"]["infinite_scroll"], True)
+ data = await (
+ await self.client.request("GET", "/prompt_manager/settings")
+ ).json()
+ self.assertIs(data["settings"]["infinite_scroll"], True)
+
+ resp = await self.client.request(
+ "POST", "/prompt_manager/settings", json={"infinite_scroll": "on"}
+ )
+ self.assertEqual(resp.status, 400)
+ self.assertTrue(PromptManagerConfig.INFINITE_SCROLL)
+
async def test_result_timeout_and_display_mode_saved(self):
resp = await self.client.request(
"POST",
diff --git a/tests/test_config.py b/tests/test_config.py
index 7daa152..5ddc9ba 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -136,6 +136,15 @@ class TestPromptManagerConfig(unittest.TestCase):
PromptManagerConfig.update_config({"performance": {"max_search_results": 50}})
self.assertEqual(PromptManagerConfig.MAX_SEARCH_RESULTS, 50)
+ def test_infinite_scroll_default_off_and_only_booleans_apply(self):
+ self.assertIn("infinite_scroll", PromptManagerConfig.get_config()["web_ui"])
+ PromptManagerConfig.update_config({"web_ui": {"infinite_scroll": True}})
+ self.assertTrue(PromptManagerConfig.INFINITE_SCROLL)
+ PromptManagerConfig.update_config({"web_ui": {"infinite_scroll": "yes"}})
+ self.assertTrue(PromptManagerConfig.INFINITE_SCROLL)
+ PromptManagerConfig.update_config({"web_ui": {"infinite_scroll": False}})
+ self.assertFalse(PromptManagerConfig.INFINITE_SCROLL)
+
def test_worker_threads_default_is_half_the_cores_and_current_is_in_range(self):
# The current value may come from config.json (loaded at import), so the
# rule is checked on the constant and only the range on the live value.
diff --git a/web/admin.html b/web/admin.html
index 6c80244..32a9abb 100644
--- a/web/admin.html
+++ b/web/admin.html
@@ -227,6 +227,11 @@
per page
+
+
Loading...
@@ -413,6 +418,14 @@
Choose how the Web UI opens from ComfyUI nodes.
+
+
+
+
+
Load the next page of prompts as you scroll. The checkbox in the list header overrides this for the current session.
+
+
+
diff --git a/web/js/admin.js b/web/js/admin.js
index 23c62c3..a4c2256 100644
--- a/web/js/admin.js
+++ b/web/js/admin.js
@@ -8,6 +8,7 @@
webuiDisplayMode: 'popup',
workerThreads: 1,
cpuCount: 1,
+ infiniteScroll: false,
};
this.categories = [];
this.tags = [];
@@ -26,6 +27,9 @@
this.api = ApiClient.createApiClient();
// Search and page loads share one guard: whichever list request is newest wins
this.listRequest = ApiClient.latestOnly();
+ this.infiniteScroll = false; // session value; seeded from settings, toggled in the header
+ this.infiniteScrollChosen = false;
+ this.loadingMore = false;
// The prompt currently edited in place, if any: { promptId, element, originalText }
this.activeEdit = null;
@@ -135,6 +139,8 @@
// Pagination controls
document.getElementById("limitSelector").addEventListener("change", (e) => this.changeLimit(parseInt(e.target.value)));
+ document.getElementById("infiniteScrollSession").addEventListener("change", (e) => this.setInfiniteScroll(e.target.checked, { session: true }));
+ document.getElementById("resultsList").addEventListener("scroll", () => this.maybeLoadMorePrompts(), { passive: true });
document.getElementById("firstPageBtn").addEventListener("click", () => this.goToPage(1));
document.getElementById("prevPageBtn").addEventListener("click", () => this.goToPage(this.pagination.currentPage - 1));
document.getElementById("nextPageBtn").addEventListener("click", () => this.goToPage(this.pagination.currentPage + 1));
@@ -293,6 +299,9 @@
if (data.success && data.settings) {
this.settings.resultTimeout = data.settings.result_timeout ?? 5;
this.settings.webuiDisplayMode = data.settings.webui_display_mode || 'popup';
+ this.settings.infiniteScroll = data.settings.infinite_scroll === true;
+ // The saved default seeds the session until the header checkbox is used
+ if (!this.infiniteScrollChosen) this.setInfiniteScroll(this.settings.infiniteScroll, { session: false });
this.settings.cpuCount = Math.max(1, parseInt(data.settings.cpu_count, 10) || 1);
this.settings.workerThreads = Math.min(
this.settings.cpuCount, Math.max(1, parseInt(data.settings.worker_threads, 10) || 1)
@@ -419,7 +428,7 @@
* Load one page of recent prompts. The page is committed to this.pagination only
* after the server answered; a newer list request supersedes one still in flight.
*/
- async loadRecentPrompts(page = 1) {
+ async loadRecentPrompts(page = 1, { append = false } = {}) {
this.listMode = "recent";
const url = PromptListSort.buildRecentUrl({
page,
@@ -434,17 +443,18 @@
{ page: this.pagination.currentPage, limit: this.pagination.limit, total: this.pagination.total },
{ page: data.pagination.page, total: data.pagination.total },
);
- this.prompts = data.results;
+ this.prompts = append ? this.prompts.concat(data.results) : data.results;
this.pagination = { ...this.pagination, currentPage: next.page, total: next.total, totalPages: next.totalPages };
this.renderPrompts();
this.updatePaginationControls();
document.getElementById("resultsTitle").textContent = "Recent Prompts";
+ this.maybeLoadMorePrompts(); // a short page may not fill the list
} catch (error) {
console.error("Recent prompts error:", error);
this.showNotification("Failed to load prompts", "error");
}
this.showListLoaded();
- document.getElementById("paginationControls").classList.remove("hidden");
+ this.updatePaginationVisibility();
}
showListLoaded() {
@@ -560,13 +570,15 @@
this.scheduleTagSuggestions(0); // offer the next tag, narrowed by this one
}
- async search(page = 1) {
+ async search(page = 1, { append = false } = {}) {
const searchText = document.getElementById("searchText").value;
const category = document.getElementById("searchCategory").value;
const tags = document.getElementById("searchTags").value;
- document.getElementById("loadingState").classList.remove("hidden");
- document.getElementById("resultsList").classList.add("hidden");
+ if (!append) {
+ document.getElementById("loadingState").classList.remove("hidden");
+ document.getElementById("resultsList").classList.add("hidden");
+ }
try {
const params = new URLSearchParams();
@@ -590,17 +602,18 @@
{ page: this.pagination.currentPage, limit, total: this.pagination.total },
{ page: reported.page ?? page, total: reported.total ?? data.results.length },
);
- this.prompts = data.results;
+ this.prompts = append ? this.prompts.concat(data.results) : data.results;
this.pagination = { ...this.pagination, currentPage: next.page, total: next.total, totalPages: next.totalPages };
this.renderPrompts();
this.updatePaginationControls();
document.getElementById("resultsTitle").textContent = "Search Results";
+ this.maybeLoadMorePrompts();
} catch (error) {
this.showNotification("Search failed", "error");
console.error("Search error:", error);
}
this.showListLoaded();
- document.getElementById("paginationControls").classList.remove("hidden");
+ this.updatePaginationVisibility();
}
currentSort() {
@@ -614,10 +627,51 @@
/** Re-fetch the page currently shown, e.g. after a prompt was edited, tagged or deleted. */
refreshList() {
- const page = this.pagination.currentPage;
+ // With infinite scroll the list holds several pages; re-fetch from the top.
+ const page = this.infiniteScroll ? 1 : this.pagination.currentPage;
return this.listMode === "search" ? this.search(page) : this.loadRecentPrompts(page);
}
+ // ── Infinite scrolling ───────────────────────────────────────────
+ setInfiniteScroll(enabled, { session }) {
+ if (session) this.infiniteScrollChosen = true;
+ const changed = this.infiniteScroll !== !!enabled;
+ this.infiniteScroll = !!enabled;
+ document.getElementById("infiniteScrollSession").checked = this.infiniteScroll;
+ this.updatePaginationVisibility();
+ if (changed && this.prompts.length) this.reloadPrompts();
+ }
+
+ updatePaginationVisibility() {
+ document.getElementById("paginationControls").classList.toggle("hidden", this.infiniteScroll);
+ }
+
+ maybeLoadMorePrompts() {
+ const el = document.getElementById("resultsList");
+ const wanted = ListState.shouldLoadMore({
+ infiniteScroll: this.infiniteScroll,
+ loading: this.loadingMore,
+ page: this.pagination.currentPage,
+ limit: this.pagination.limit,
+ total: this.pagination.total,
+ scrollTop: el.scrollTop,
+ clientHeight: el.clientHeight,
+ scrollHeight: el.scrollHeight,
+ });
+ if (wanted) this.loadMorePrompts();
+ }
+
+ async loadMorePrompts() {
+ if (this.loadingMore) return;
+ this.loadingMore = true;
+ try {
+ const page = this.pagination.currentPage + 1;
+ await (this.listMode === "search" ? this.search(page, { append: true }) : this.loadRecentPrompts(page, { append: true }));
+ } finally {
+ this.loadingMore = false;
+ }
+ }
+
handleActionClick(e) {
const target = e.target.closest("[data-action]");
if (target) DataActions.dispatch(target.dataset, this.actionHandlers, target);
@@ -911,6 +965,7 @@
showSettingsModal() {
document.getElementById("resultTimeout").value = this.settings.resultTimeout;
document.getElementById("webuiDisplayMode").value = this.settings.webuiDisplayMode;
+ document.getElementById("infiniteScrollDefault").checked = this.settings.infiniteScroll;
const threads = document.getElementById("workerThreads");
threads.max = this.settings.cpuCount;
threads.value = this.settings.workerThreads;
@@ -1110,11 +1165,13 @@
const timeout = parseInt(document.getElementById("resultTimeout").value);
const displayMode = document.getElementById("webuiDisplayMode").value;
const workerThreads = parseInt(document.getElementById("workerThreads").value, 10) || 1;
+ const infiniteScroll = document.getElementById("infiniteScrollDefault").checked;
const galleryPaths = this._collectScanPaths().filter(p => p !== '');
this.settings.resultTimeout = timeout;
this.settings.webuiDisplayMode = displayMode;
this.settings.workerThreads = workerThreads;
+ this.settings.infiniteScroll = infiniteScroll;
this.settings.galleryRootPaths = galleryPaths;
try {
@@ -1125,6 +1182,7 @@
result_timeout: timeout,
webui_display_mode: displayMode,
worker_threads: workerThreads,
+ infinite_scroll: infiniteScroll,
gallery_root_paths: galleryPaths
}),
});
@@ -1135,6 +1193,8 @@
// Save LoRA integration settings (fire-and-forget)
await this.saveLoraSettings();
+ this.setInfiniteScroll(infiniteScroll, { session: false });
+
if (data.restart_required) {
this.showNotification("Settings saved. Restart ComfyUI for gallery path changes to take effect.", "warning");
} else {