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
This commit is contained in:
Vito Sansevero
2026-10-08 18:01:35 -07:00
parent f93412ab90
commit 08d57b408a
6 changed files with 130 additions and 9 deletions
+12
View File
@@ -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)
+4
View File
@@ -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:
+23
View File
@@ -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",
+9
View File
@@ -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.
+13
View File
@@ -227,6 +227,11 @@
</select>
<span class="text-xs text-pm-secondary">per page</span>
</div>
<!-- Session-only toggle; the saved default lives in Settings -->
<label class="flex items-center gap-1.5 text-xs text-pm-secondary cursor-pointer" title="Load the next page as you scroll (this session)">
<input type="checkbox" id="infiniteScrollSession" class="w-3.5 h-3.5 rounded border-pm bg-pm-input text-pm-accent focus:ring-pm-accent">
Infinite scroll
</label>
</div>
<span class="text-xs font-medium text-pm-secondary bg-pm-input px-2 py-0.5 rounded-full" id="resultsCount">Loading...</span>
</div>
@@ -413,6 +418,14 @@
</select>
<p class="text-xs text-pm-muted mt-1">Choose how the Web UI opens from ComfyUI nodes.</p>
</div>
<div class="mt-3 flex items-center justify-between">
<div>
<label for="infiniteScrollDefault" class="block text-xs font-medium text-pm-secondary">Infinite Scrolling</label>
<p class="text-xs text-pm-muted">Load the next page of prompts as you scroll. The checkbox in the list header overrides this for the current session.</p>
</div>
<input type="checkbox" id="infiniteScrollDefault" class="w-4 h-4 rounded border-pm bg-pm-input text-pm-accent focus:ring-pm-accent ml-3">
</div>
</div>
<!-- Performance Section -->
+69 -9
View File
@@ -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 {