From 08d57b408ab2dca7b135a47bec40eea74c196102 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Thu, 8 Oct 2026 17:58:50 -0700 Subject: [PATCH] feat(dashboard): infinite scrolling for the prompt list, saved default plus session toggle - PromptManagerConfig.INFINITE_SCROLL (web_ui.infinite_scroll) is reported and saved through the settings API; only booleans are accepted - Settings modal gains an "Infinite Scrolling" default; saving applies it at once - the results header gains an "Infinite scroll" checkbox that overrides the default for the current session without saving - when on, the next page is appended as the list nears its bottom (same load-more rule as the gallery), page controls hide, a short page keeps loading until the list fills, and a refresh after an edit restarts from the top --- py/api/admin.py | 12 +++++++ py/config.py | 4 +++ tests/test_api_admin.py | 23 ++++++++++++ tests/test_config.py | 9 +++++ web/admin.html | 13 +++++++ web/js/admin.js | 78 ++++++++++++++++++++++++++++++++++++----- 6 files changed, 130 insertions(+), 9 deletions(-) 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 {