Add Action Loader Module with Stacking Support and STscript Commands (#5311) * Add action loader utility with stoppable toast notifications * Add slash commands for action loader control (/loader-wrap, /loader-show, /loader-hide, /loader-stop) * Refactor action loader to support stacking multiple loaders with individual toast management - Convert action loader from singleton to class-based handle system (ActionLoaderHandle) - Support multiple concurrent loaders with single overlay and stacked toasts - Add unique ID generation and tracking for each loader instance - Implement onHide callback alongside existing onStop callback - Add getActiveLoaderHandles() and getLoaderHandleById() utility functions - Refactor hideActionLoader() to accept... * Improve action loader overlay and toast handling with better state checks and cleanup timing - Check isLoaderDisplayed() before showing overlay to prevent conflicts with existing loaders - Use toastr.options.hideDuration for toast removal timing instead of hardcoded 250ms - Simplify hideActionLoader() by using getActiveLoaderHandles() helper - Remove redundant empty check in hideActionLoader() - Add clarifying comment for intentional error throw in createClosureHandler() - Remove redundant 'onStop' default * fix stop button toast removal issue by using toastr.clear force option instead of manual removal with timeout * Fix isLoaderDisplayed() by using double negation operator instead of null comparison * Add non-blocking loader support with `blocking` parameter for toast-only action loaders - Add `blocking` option to ActionLoaderHandle (default: true) - Implement hasBlockingLoaders() helper to check for active blocking loaders - Show/hide overlay only when blocking loaders are active - Add `blocking` named argument to /loader-wrap and /loader-show commands - Update help strings with non-blocking usage examples - Import commonEnumProviders for boolean enum and isFalseBoolean utility * Add optional title parameter to action loader toast notifications and reorder constructor parameters for consistency - Add `title` parameter to ActionLoaderOptions and ActionLoaderHandle constructor - Pass title to toastr.info() for toast notifications - Reorder parameters: blocking, toastMode, message, title, stopTooltip (grouped by importance) - Add warning when creating non-blocking loader without toast (invisible to user) - Update /loader-wrap and /loader-show commands with title argument * Add loader utility API to action-loader and expose it in ST context for convenient programmatic access - Create `loader` object with show/hide/active/get methods and ToastMode/Handle exports - Add JSDoc examples for basic usage, non-blocking tasks, and hiding all loaders - Import and expose `loader` in ST context alongside existing loader functions * Split slash command and functional modules * Create abort controller on app init * Remove HTML tags from "returns" declaration --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>

a6486d7f081223a4ded0853deb2fbd46f5be18f0

Wolfsblvt <wolfsblvt@gmail.com>

Signed
9 files changed, +833 -8Ignore whitespace
public/css/loader.css+24 -0
@@ -29,3 +29,27 @@
2929 align-items: center;
3030 justify-content: center;
3131}
32+
33+/* Action loader toast styles */
34+.action-loader-toast {
35+ display: flex;
36+ align-items: center;
37+ gap: 10px;
38+ width: 100%;
39+}
40+
41+.action-loader-message {
42+ flex: 1;
43+}
44+
45+.action-loader-stop {
46+ cursor: pointer;
47+ font-size: 1.2em;
48+ opacity: 0.8;
49+ transition: opacity 0.15s ease, color 0.15s ease;
50+}
51+
52+.action-loader-stop:hover {
53+ opacity: 1;
54+ color: color-mix(in srgb, currentColor 40%, #e74c3c 60%);
55+}
public/script.js+33 -7
@@ -361,12 +361,36 @@ toastr.options = {
361361 // so the toasts still show up inside there.
362362 fixToastrForDialogs();
363363 },
364- onShown: function () {
365- // Set tooltip to the notification message
366- $(this).attr('title', t`Tap to close`);
367- },
368364};
369365
366+// Run once during startup
367+toastr.subscribe(function (args) {
368+ if (args.state !== 'visible') {
369+ return;
370+ }
371+
372+ const $container = toastr.getContainer(args.options, false);
373+ if (!$container || !$container.length) {
374+ return;
375+ }
376+
377+ // toastr has already inserted the element at this point
378+ const $toast = args.options.newestOnTop
379+ ? $container.children().first()
380+ : $container.children().last();
381+
382+ // Meaning of "clickable":
383+ // Interactable unless tapToDismiss was explicitly false
384+ const isInteractable = args.options.tapToDismiss !== false;
385+ $toast.toggleClass('interactable', isInteractable);
386+ if (isInteractable) {
387+ $toast.attr('title', t`Tap to close`);
388+ } else {
389+ $toast.removeAttr('title');
390+ $toast.addClass('toast-non-interactable');
391+ }
392+});
393+
370394export const characterGroupOverlay = new BulkEditOverlay();
371395
372396// Markdown converter
@@ -600,8 +624,7 @@ export let recentSwipes = 0;
600624export let extension_prompts = {};
601625
602626export let main_api;// = "kobold";
603-/** @type {AbortController} */
627+let abortController = new AbortController();
604-let abortController;
605628
606629//css
607630var css_send_form_display = $('<div id=send_form></div>').css('display');
@@ -3862,7 +3885,10 @@ export async function generateRawData({ prompt = '', api = null, instructOverrid
38623885
38633886 // Allow extensions to stop generation before it happens
38643887 const eventAbortController = new AbortController();
38653888 const abortHook = () => eventAbortController.abort(new Error('Cancelled by extension'));{
3889+ abortController.abort(new Error('Cancelled by stop event'));
3890+ eventAbortController.abort(new Error('Cancelled by extension'));
3891+ };
38663892 eventSource.on(event_types.GENERATION_STOPPED, abortHook);
38673893
38683894 try {
public/scripts/action-loader-slashcommands.js+355 -0
@@ -0,0 +1,355 @@
1+import { ActionLoaderToastMode, getActiveLoaderHandles, getLoaderHandleById, hideActionLoader, showActionLoader } from './action-loader.js';
2+import { t } from './i18n.js';
3+import { SlashCommand } from './slash-commands/SlashCommand.js';
4+import { SlashCommandNamedArgument, ARGUMENT_TYPE, SlashCommandArgument } from './slash-commands/SlashCommandArgument.js';
5+import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js';
6+import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';
7+import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js';
8+import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
9+import { isFalseBoolean } from './utils.js';
10+
11+/**
12+ * Registers slash commands for the action loader module.
13+ */
14+export function registerActionLoaderSlashCommands() {
15+ /**
16+ * Helper to create a closure-based handler from a SlashCommandClosure argument.
17+ * Allows all possible slash command arg types to be passed in, but only closure is accepted.
18+ * @param {string | SlashCommandClosure | (string | SlashCommandClosure)[]} closure - The closure argument
19+ * @param {Object} options - Configuration options
20+ * @param {string} [options.argName='onStop'] - Name of the argument for error messages
21+ * @param {boolean} [options.throwInvalid=true] - Whether to throw an error for invalid input
22+ * @returns {(() => Promise<void>)|null} The handler function, or null if no closure
23+ */
24+ function createClosureHandler(closure, { argName = 'onStop', throwInvalid = true } = {}) {
25+ if (!(closure instanceof SlashCommandClosure)) {
26+ if (closure && throwInvalid) {
27+ // Throw error on purpose. This is defined as a syntax error.
28+ throw new Error(t`Invalid argument for ${argName} provided. This is not a closure.`);
29+ }
30+ return null;
31+ }
32+ return async () => {
33+ try {
34+ const localClosure = closure.getCopy();
35+ localClosure.onProgress = () => { };
36+ await localClosure.execute();
37+ } catch (e) {
38+ console.error('Error executing closure handler', e);
39+ }
40+ };
41+ }
42+
43+ // Shared loader enum providers
44+ const loaderEnumProviders = {
45+ toastModeEnumProvider: () => [
46+ new SlashCommandEnumValue(ActionLoaderToastMode.NONE, 'No toast displayed', enumTypes.enum, enumIcons.disabled),
47+ new SlashCommandEnumValue(ActionLoaderToastMode.STATIC, 'Static toast without stop button', enumTypes.enum, enumIcons.spinner),
48+ new SlashCommandEnumValue(ActionLoaderToastMode.STOPPABLE, 'Toast with stop button (default)', enumTypes.enum, enumIcons.stop),
49+ ],
50+ loaderHandleProvider: () => getActiveLoaderHandles().map(
51+ handle => new SlashCommandEnumValue(handle.id, `Active loader: ${handle.id}`, enumTypes.enum, enumIcons.spinner),
52+ ).concat(
53+ new SlashCommandEnumValue('Temporary loader handle', 'Any loader handle saved in variables or similar', 'enum', '📄', () => true, () => ''),
54+ ),
55+ };
56+
57+ // /loader-wrap command - wraps a closure with loader display
58+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
59+ name: 'loader-wrap',
60+ returns: 'result of the closure execution',
61+ helpString: `
62+ <div>
63+ Wraps a closure execution with an action loader overlay and optional toast notification.
64+ By default, the loader blocks UI interaction until the closure completes.
65+ Multiple loaders can be stacked - each gets its own toast, but the overlay stays single.
66+ </div>
67+ <div>
68+ <strong>Toast modes:</strong>
69+ <ul>
70+ <li><code>stoppable</code> - Shows toast with a stop button (default)</li>
71+ <li><code>static</code> - Shows toast without stop button</li>
72+ <li><code>none</code> - No toast, only loader overlay</li>
73+ </ul>
74+ </div>
75+ <div>
76+ Set <code>blocking=false</code> to show only a toast without blocking the UI.
77+ Useful for background operations like image captioning or generation.
78+ </div>
79+ <div>
80+ The default stop behavior is calling <code>stopGeneration()</code>.
81+ If the wrapped action is doing something different than generating, a custom stop closure can be provided.
82+ </div>
83+ <div>
84+ <strong>Examples:</strong>
85+ <ul>
86+ <li><pre><code class="language-stscript">/loader-wrap message="Generating summary..." {: /gen Summary of the last message | /echo Done :}</code></pre></li>
87+ <li><pre><code class="language-stscript">/loader-wrap blocking=false message="Captioning..." {: /caption :}</code></pre></li>
88+ <li><pre><code class="language-stscript">/loader-wrap toast=stoppable onStop={: /echo "Stopped by user" :} {: /delay 10000 :}</code></pre></li>
89+ </ul>
90+ </div>
91+ `,
92+ namedArgumentList: [
93+ SlashCommandNamedArgument.fromProps({
94+ name: 'blocking',
95+ description: 'Whether to show blocking overlay. Set to false for non-blocking toast-only loaders.',
96+ typeList: [ARGUMENT_TYPE.BOOLEAN],
97+ defaultValue: 'true',
98+ enumList: commonEnumProviders.boolean()(),
99+ }),
100+ SlashCommandNamedArgument.fromProps({
101+ name: 'toast',
102+ description: 'Toast display mode: stoppable (with stop button), static (no stop button), or none',
103+ typeList: [ARGUMENT_TYPE.STRING],
104+ defaultValue: ActionLoaderToastMode.STOPPABLE,
105+ enumList: loaderEnumProviders.toastModeEnumProvider(),
106+ }),
107+ SlashCommandNamedArgument.fromProps({
108+ name: 'message',
109+ description: 'Message to display in the toast notification',
110+ typeList: [ARGUMENT_TYPE.STRING],
111+ defaultValue: 'Generating...',
112+ }),
113+ SlashCommandNamedArgument.fromProps({
114+ name: 'title',
115+ description: 'Optional title for the toast notification',
116+ typeList: [ARGUMENT_TYPE.STRING],
117+ }),
118+ SlashCommandNamedArgument.fromProps({
119+ name: 'stopTooltip',
120+ description: 'Tooltip text for the stop button (only used when toast=stoppable)',
121+ typeList: [ARGUMENT_TYPE.STRING],
122+ defaultValue: 'Stop',
123+ }),
124+ SlashCommandNamedArgument.fromProps({
125+ name: 'onStop',
126+ description: 'Closure to execute when the stop button is clicked. If not provided, uses default stop behavior.',
127+ typeList: [ARGUMENT_TYPE.CLOSURE],
128+ }),
129+ ],
130+ unnamedArgumentList: [
131+ SlashCommandArgument.fromProps({
132+ description: 'Closure to execute while the loader is displayed',
133+ typeList: [ARGUMENT_TYPE.CLOSURE],
134+ isRequired: true,
135+ }),
136+ ],
137+ callback: async (args, value) => {
138+ if (!(value instanceof SlashCommandClosure)) {
139+ // Throw error on purpose. This is defined as a syntax error.
140+ throw new Error(t`Invalid argument for unnamed argument provided. This is not a closure.`);
141+ }
142+
143+ const blocking = !isFalseBoolean(String(args.blocking));
144+ const toastMode = Object.values(ActionLoaderToastMode).includes(String(args.toast))
145+ ? String(args.toast)
146+ : ActionLoaderToastMode.STOPPABLE;
147+ const message = String(args.message ?? t`Generating...`);
148+ const title = args.title ? String(args.title) : '';
149+ const stopTooltip = String(args.stopTooltip ?? t`Stop`);
150+
151+ const loader = showActionLoader({
152+ blocking,
153+ toastMode,
154+ message,
155+ title,
156+ stopTooltip,
157+ onStop: createClosureHandler(args.onStop),
158+ });
159+
160+ try {
161+ const closureCopy = value.getCopy();
162+ const result = await closureCopy.execute();
163+ return result.pipe;
164+ } finally {
165+ await loader.hide();
166+ }
167+ },
168+ }));
169+
170+ // /loader-show command - manually show a loader, returns handle ID
171+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
172+ name: 'loader-show',
173+ returns: 'loader handle ID (use with /loader-hide)',
174+ helpString: `
175+ <div>
176+ Manually shows an action loader. Returns a handle ID that can be used with <code>/loader-hide</code> to hide it.
177+ Use this for fine-grained control when you need to show/hide the loader at specific points.
178+ Multiple loaders can be stacked - each gets its own toast, but the overlay stays single.
179+ </div>
180+ <div>
181+ <strong>Toast modes:</strong>
182+ <ul>
183+ <li><code>stoppable</code> - Shows toast with a stop button (default)</li>
184+ <li><code>static</code> - Shows toast without stop button</li>
185+ <li><code>none</code> - No toast, only loader overlay</li>
186+ </ul>
187+ </div>
188+ <div>
189+ Set <code>blocking=false</code> to show only a toast without blocking the UI.
190+ Useful for background operations like image captioning or generation.
191+ </div>
192+ <div>
193+ The default stop behavior is calling <code>stopGeneration()</code>.
194+ If the wrapped action is doing something different than generating, a custom stop closure can be provided.
195+ </div>
196+ <div>
197+ <strong>Example:</strong>
198+ <pre>
199+ <code class="language-stscript">
200+/loader-show message="Loading..." |
201+/setvar key=myLoader |
202+/some-operation |
203+/loader-hide handle={{getvar::myLoader}}
204+ </code>
205+ </pre>
206+ </div>
207+ `,
208+ namedArgumentList: [
209+ SlashCommandNamedArgument.fromProps({
210+ name: 'blocking',
211+ description: 'Whether to show blocking overlay. Set to false for non-blocking toast-only loaders.',
212+ typeList: [ARGUMENT_TYPE.BOOLEAN],
213+ defaultValue: 'true',
214+ enumList: commonEnumProviders.boolean()(),
215+ }),
216+ SlashCommandNamedArgument.fromProps({
217+ name: 'toast',
218+ description: 'Toast display mode: stoppable (with stop button), static (no stop button), or none',
219+ typeList: [ARGUMENT_TYPE.STRING],
220+ defaultValue: ActionLoaderToastMode.STOPPABLE,
221+ enumList: loaderEnumProviders.toastModeEnumProvider(),
222+ }),
223+ SlashCommandNamedArgument.fromProps({
224+ name: 'message',
225+ description: 'Message to display in the toast notification',
226+ typeList: [ARGUMENT_TYPE.STRING],
227+ defaultValue: 'Generating...',
228+ }),
229+ SlashCommandNamedArgument.fromProps({
230+ name: 'title',
231+ description: 'Optional title for the toast notification',
232+ typeList: [ARGUMENT_TYPE.STRING],
233+ }),
234+ SlashCommandNamedArgument.fromProps({
235+ name: 'stopTooltip',
236+ description: 'Tooltip text for the stop button (only used when toast=stoppable)',
237+ typeList: [ARGUMENT_TYPE.STRING],
238+ defaultValue: 'Stop',
239+ }),
240+ SlashCommandNamedArgument.fromProps({
241+ name: 'onStop',
242+ description: 'Closure to execute when the stop button is clicked',
243+ typeList: [ARGUMENT_TYPE.CLOSURE],
244+ }),
245+ SlashCommandNamedArgument.fromProps({
246+ name: 'onHide',
247+ description: 'Closure to execute when the loader is hidden (not stopped)',
248+ typeList: [ARGUMENT_TYPE.CLOSURE],
249+ }),
250+ ],
251+ unnamedArgumentList: [],
252+ callback: async (args) => {
253+ const blocking = !isFalseBoolean(String(args.blocking));
254+ const toastMode = Object.values(ActionLoaderToastMode).includes(String(args.toast))
255+ ? String(args.toast)
256+ : ActionLoaderToastMode.STOPPABLE;
257+ const message = String(args.message ?? t`Generating...`);
258+ const title = args.title ? String(args.title) : '';
259+ const stopTooltip = String(args.stopTooltip ?? t`Stop`);
260+
261+ const handle = showActionLoader({
262+ blocking,
263+ toastMode,
264+ message,
265+ title,
266+ stopTooltip,
267+ onStop: createClosureHandler(args.onStop),
268+ onHide: createClosureHandler(args.onHide, { argName: 'onHide' }),
269+ });
270+
271+ return handle.id;
272+ },
273+ }));
274+
275+ // /loader-hide command - manually hide a loader by handle ID
276+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
277+ name: 'loader-hide',
278+ returns: 'true if an active loader was hidden, otherwise false',
279+ helpString: `
280+ <div>
281+ Hides an action loader that was shown with <code>/loader-show</code>.
282+ If no handle is provided, hides <strong>all</strong> active loaders.
283+ </div>
284+ <div>
285+ <strong>Example:</strong>
286+ <pre><code class="language-stscript">/loader-hide handle={{getvar::myLoader}}</code></pre>
287+ </div>
288+ `,
289+ namedArgumentList: [
290+ SlashCommandNamedArgument.fromProps({
291+ name: 'handle',
292+ description: 'Loader handle ID returned by /loader-show. If not provided, hides all active loaders.',
293+ typeList: [ARGUMENT_TYPE.STRING],
294+ enumProvider: loaderEnumProviders.loaderHandleProvider,
295+ }),
296+ ],
297+ callback: async (args) => {
298+ const handleId = args.handle ? String(args.handle) : null;
299+
300+ if (handleId) {
301+ const handle = getLoaderHandleById(handleId);
302+ if (handle && handle.isActive) {
303+ await handle.hide();
304+ return 'true';
305+ }
306+ return 'false';
307+ }
308+
309+ // No handle provided - hide all active loaders
310+ const result = await hideActionLoader();
311+ return result ? 'true' : 'false';
312+ },
313+ }));
314+
315+ // /loader-stop command - trigger the stop action on a loader
316+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
317+ name: 'loader-stop',
318+ returns: 'true if an active loader was stopped, otherwise false',
319+ helpString: `
320+ <div>
321+ Triggers the stop action on a specific action loader, as if the user clicked the stop button.
322+ Unlike <code>/loader-hide</code>, this command requires a handle - you must specify which loader to stop.
323+ </div>
324+ <div>
325+ <strong>Example:</strong>
326+ <pre><code class="language-stscript">/loader-stop handle={{getvar::myLoader}}</code></pre>
327+ </div>
328+ `,
329+ namedArgumentList: [
330+ SlashCommandNamedArgument.fromProps({
331+ name: 'handle',
332+ description: 'Loader handle ID returned by /loader-show.',
333+ typeList: [ARGUMENT_TYPE.STRING],
334+ isRequired: true,
335+ enumProvider: loaderEnumProviders.loaderHandleProvider,
336+ }),
337+ ],
338+ callback: async (args) => {
339+ const handleId = args.handle ? String(args.handle) : null;
340+
341+ if (!handleId) {
342+ toastr.warning(t`No handle provided. You must specify which loader to stop.`);
343+ return 'false';
344+ }
345+
346+ const handle = getLoaderHandleById(handleId);
347+ if (handle && handle.isActive) {
348+ await handle.stop();
349+ return 'true';
350+ }
351+
352+ return 'false';
353+ },
354+ }));
355+}
public/scripts/action-loader.js+406 -0
@@ -0,0 +1,406 @@
1+/**
2+ * Action loader utility - shows loader overlay with stoppable toast notification.
3+ * Designed to be flexible and reusable for various long-running operations.
4+ * Supports stacking multiple loaders - overlay stays single, but toasts can stack.
5+ *
6+ * With default arguments, will function as a generation loader / wrapper.
7+ *
8+ * @module action-loader
9+ */
10+
11+import { t } from './i18n.js';
12+import { stopGeneration } from '../script.js';
13+import { showLoader, hideLoader, isLoaderDisplayed } from './loader.js';
14+
15+/**
16+ * Enum representing the toast display mode for the action loader.
17+ * @readonly
18+ * @enum {string}
19+ */
20+export const ActionLoaderToastMode = {
21+ /** No toast is displayed */
22+ NONE: 'none',
23+ /** Toast is displayed without stop button (non-interactable) */
24+ STATIC: 'static',
25+ /** Toast is displayed with stop button (default) */
26+ STOPPABLE: 'stoppable',
27+};
28+
29+/**
30+ * @typedef {object} ActionLoaderOptions
31+ * @property {boolean} [blocking=true] - Whether to show the blocking overlay. Set to false for non-blocking toast-only loaders.
32+ * @property {ActionLoaderToastMode} [toastMode='stoppable'] - Toast display mode
33+ * @property {string} [message='Generating...'] - The message to display in the toast
34+ * @property {string} [title] - Optional title for the toast notification
35+ * @property {string} [stopTooltip='Stop'] - Tooltip text for the stop button
36+ * @property {(() => void)|null} [onStop=null] - Custom stop handler. If null, calls `stopGeneration()`
37+ * @property {(() => void)|null} [onHide=null] - Custom hide handler. Called when the loader is hidden (not stopped).
38+ */
39+
40+/** Counter for generating unique loader IDs */
41+let loaderIdCounter = 0;
42+
43+/** @type {Set<ActionLoaderHandle>} Set of all active loader handles */
44+const activeHandles = new Set();
45+
46+/**
47+ * Generates a unique loader ID.
48+ * @returns {string} Unique loader ID
49+ */
50+function generateLoaderId() {
51+ return `loader_${++loaderIdCounter}`;
52+}
53+
54+/**
55+ * Checks if there are any active blocking loaders.
56+ * @returns {boolean} True if at least one blocking loader is active
57+ */
58+function hasBlockingLoaders() {
59+ for (const handle of activeHandles) {
60+ if (handle.isBlocking && handle.isActive) {
61+ return true;
62+ }
63+ }
64+ return false;
65+}
66+
67+/**
68+ * Class representing an action loader handle.
69+ * Manages its own toast, stop handler, and lifecycle.
70+ */
71+export class ActionLoaderHandle {
72+ /** @type {string} Unique identifier for this handle */
73+ id;
74+
75+ /** @type {JQuery<HTMLElement>|null} The toast element for this loader */
76+ #toast = null;
77+
78+ /** @type {(() => void)|null} Custom stop handler */
79+ #onStop = null;
80+
81+ /** @type {(() => void)|null} Custom hide handler */
82+ #onHide = null;
83+
84+ /** @type {boolean} Whether this loader blocks the UI with an overlay */
85+ #blocking = true;
86+
87+ /** @type {boolean} Whether this handle has been disposed */
88+ #disposed = false;
89+
90+ /**
91+ * Creates a new ActionLoaderHandle.
92+ * @param {object} options - Configuration options
93+ * @param {boolean} [options.blocking=true] - Whether to show blocking overlay
94+ * @param {ActionLoaderToastMode} [options.toastMode] - Toast display mode
95+ * @param {string} [options.message='Generating...'] - Message to display in the toast
96+ * @param {string} [options.title] - Title for the toast notification
97+ * @param {string} [options.stopTooltip='Stop'] - Tooltip for the stop button
98+ * @param {(() => void)|null} [options.onStop] - Custom stop handler
99+ * @param {(() => void)|null} [options.onHide] - Custom hide handler
100+ */
101+ constructor({
102+ blocking = true,
103+ toastMode = ActionLoaderToastMode.STOPPABLE,
104+ message = t`Generating...`,
105+ title = '',
106+ stopTooltip = t`Stop`,
107+ onStop = null,
108+ onHide = null,
109+ } = {}) {
110+ this.id = generateLoaderId();
111+ this.#blocking = blocking;
112+ this.#onStop = onStop;
113+ this.#onHide = onHide;
114+
115+ // Warn if non-blocking loader has no toast - it won't be visible to the user
116+ if (!blocking && toastMode === ActionLoaderToastMode.NONE) {
117+ console.warn('[ActionLoader] Non-blocking loader created without a toast. This loader will not be visible to the user.');
118+ }
119+
120+ // Show the blocking loader overlay if this is the first blocking handle
121+ if (blocking && !hasBlockingLoaders() && !isLoaderDisplayed()) {
122+ showLoader();
123+ }
124+
125+ // Register this handle
126+ activeHandles.add(this);
127+
128+ // Create toast if needed
129+ if (toastMode !== ActionLoaderToastMode.NONE) {
130+ this.#createToast(message, title, toastMode, stopTooltip);
131+ }
132+ }
133+
134+ /**
135+ * Creates the toast element for this loader.
136+ * @param {string} message - Message to display
137+ * @param {string} title - Title for the toast
138+ * @param {ActionLoaderToastMode} toastMode - Toast mode
139+ * @param {string} stopTooltip - Tooltip for stop button
140+ */
141+ #createToast(message, title, toastMode, stopTooltip) {
142+ const toastContent = document.createElement('div');
143+ toastContent.className = 'action-loader-toast';
144+
145+ const messageSpan = document.createElement('span');
146+ messageSpan.className = 'action-loader-message';
147+ messageSpan.textContent = message;
148+ toastContent.appendChild(messageSpan);
149+
150+ // Add stop button if mode is STOPPABLE
151+ if (toastMode === ActionLoaderToastMode.STOPPABLE) {
152+ const stopButton = document.createElement('i');
153+ stopButton.className = 'fa-solid fa-stop-circle action-loader-stop interactable';
154+ stopButton.title = stopTooltip;
155+ stopButton.addEventListener('click', (e) => {
156+ e.preventDefault();
157+ e.stopPropagation();
158+ this.stop();
159+ });
160+ toastContent.appendChild(stopButton);
161+ }
162+
163+ // Show toast with no timeout (sticky)
164+ this.#toast = toastr.info($(toastContent), title, {
165+ timeOut: 0,
166+ extendedTimeOut: 0,
167+ tapToDismiss: false,
168+ escapeHtml: false,
169+ });
170+ }
171+
172+ /**
173+ * Clears the toast element for this loader.
174+ */
175+ #clearToast() {
176+ if (this.#toast) {
177+ toastr.clear(this.#toast, { force: true }); // Need to force as the toast might have focus/hover
178+ this.#toast = null;
179+ }
180+ }
181+
182+ /**
183+ * Disposes this handle, removing it from active handles and hiding overlay if last.
184+ */
185+ async #dispose() {
186+ if (this.#disposed) return;
187+ this.#disposed = true;
188+
189+ this.#clearToast();
190+ activeHandles.delete(this);
191+
192+ // Hide the overlay if this was the last blocking handle
193+ if (this.#blocking && !hasBlockingLoaders()) {
194+ await hideLoader();
195+ }
196+ }
197+
198+ /**
199+ * Whether this handle is still active (not disposed).
200+ * @returns {boolean}
201+ */
202+ get isActive() {
203+ return !this.#disposed;
204+ }
205+
206+ /**
207+ * Whether this loader blocks the UI with an overlay.
208+ * @returns {boolean}
209+ */
210+ get isBlocking() {
211+ return this.#blocking;
212+ }
213+
214+ /**
215+ * Triggers the stop action on this loader.
216+ * Calls the custom onStop handler if provided, otherwise calls stopGeneration().
217+ * Then hides this loader.
218+ */
219+ async stop() {
220+ if (this.#disposed) return;
221+
222+ // Call custom stop handler or default
223+ if (this.#onStop) {
224+ try {
225+ await this.#onStop();
226+ } catch (e) {
227+ console.error('Error executing onStop handler', e);
228+ }
229+ } else {
230+ stopGeneration();
231+ }
232+
233+ // Dispose without calling onHide (stop is different from hide)
234+ await this.#dispose();
235+ }
236+
237+ /**
238+ * Hides this loader and clears its toast.
239+ * Calls the custom onHide handler if provided.
240+ */
241+ async hide() {
242+ if (this.#disposed) return;
243+
244+ // Call custom hide handler if provided
245+ if (this.#onHide) {
246+ try {
247+ await this.#onHide();
248+ } catch (e) {
249+ console.error('Error executing onHide handler', e);
250+ }
251+ }
252+
253+ await this.#dispose();
254+ }
255+}
256+
257+/**
258+ * Action loader utility API.
259+ * Provides a convenient interface for showing and managing loading indicators.
260+ *
261+ * Read the functions documentation for more details.
262+ *
263+ * @example
264+ * // Basic usage
265+ * const handle = loader.show({ message: 'Loading...' });
266+ * await someOperation();
267+ * handle.hide();
268+ *
269+ * @example
270+ * // Non-blocking background task
271+ * const handle = loader.show({ blocking: false, message: 'Processing...' });
272+ *
273+ * @example
274+ * // Hide all active loaders
275+ * loader.hide();
276+ */
277+export const loader = {
278+ /**
279+ * Shows an action loader with optional toast notification.
280+ * Returns a handle to control the loader.
281+ * @type {typeof showActionLoader}
282+ */
283+ show: showActionLoader,
284+
285+ /**
286+ * Hides a specific loader by handle, or all loaders if no handle provided.
287+ * @type {typeof hideActionLoader}
288+ */
289+ hide: hideActionLoader,
290+
291+ /**
292+ * Gets all currently active loader handles.
293+ * @type {typeof getActiveLoaderHandles}
294+ */
295+ active: getActiveLoaderHandles,
296+
297+ /**
298+ * Gets a loader handle by its ID.
299+ * @type {typeof getLoaderHandleById}
300+ */
301+ get: getLoaderHandleById,
302+
303+ /**
304+ * Toast display mode constants.
305+ * @type {typeof ActionLoaderToastMode}
306+ */
307+ ToastMode: ActionLoaderToastMode,
308+
309+ /**
310+ * The ActionLoaderHandle class.
311+ * @type {typeof ActionLoaderHandle}
312+ */
313+ Handle: ActionLoaderHandle,
314+};
315+
316+/**
317+ * Shows an action loader with an optional stoppable toast notification.
318+ * Multiple loaders can be stacked - the overlay stays single, but each gets its own toast.
319+ * When the last loader is hidden, the overlay is removed.
320+ *
321+ * With default arguments, will function as a generation loader / wrapper.
322+ *
323+ * @param {ActionLoaderOptions} [options={}] - Configuration options
324+ * @returns {ActionLoaderHandle} Handle to control the loader
325+ *
326+ * @example
327+ * // Basic usage
328+ * const loader = showActionLoader({ message: 'Generating title...' });
329+ * try {
330+ * const result = await generateRaw({ prompt });
331+ * // process result
332+ * } finally {
333+ * await loader.hide();
334+ * }
335+ *
336+ * @example
337+ * // With custom stop and hide handlers
338+ * const loader = showActionLoader({
339+ * message: 'Downloading...',
340+ * stopTooltip: 'Cancel download',
341+ * onStop: () => myCustomCancelFunction(),
342+ * onHide: () => console.log('Loader hidden'),
343+ * });
344+ *
345+ * @example
346+ * // Stacking multiple loaders
347+ * const loader1 = showActionLoader({ message: 'Task 1...' });
348+ * const loader2 = showActionLoader({ message: 'Task 2...' });
349+ * await loader1.hide(); // Overlay stays, loader2 still active
350+ * await loader2.hide(); // Now overlay hides
351+ *
352+ * @example
353+ * // Non-blocking loader (toast only, no overlay)
354+ * const loader = showActionLoader({
355+ * message: 'Captioning image...',
356+ * blocking: false,
357+ * onStop: () => abortCaptioning(),
358+ * });
359+ */
360+export function showActionLoader(options = {}) {
361+ return new ActionLoaderHandle(options);
362+}
363+
364+/**
365+ * Hides a specific action loader by handle, or all active loaders if no handle provided.
366+ * @param {ActionLoaderHandle|null} [handle=null] - Specific handle to hide, or undefined to hide all
367+ * @returns {Promise<boolean>} Whether any loader was hidden
368+ */
369+export async function hideActionLoader(handle = null) {
370+ if (handle instanceof ActionLoaderHandle) {
371+ if (handle.isActive) {
372+ await handle.hide();
373+ return true;
374+ }
375+ return false;
376+ }
377+
378+ // No handle provided - hide all active loaders
379+ const handles = getActiveLoaderHandles();
380+ for (const h of handles) {
381+ await h.hide();
382+ }
383+ return handles.length > 0;
384+}
385+
386+/**
387+ * Gets all currently active loader handles.
388+ * @returns {ActionLoaderHandle[]} Array of active handles
389+ */
390+export function getActiveLoaderHandles() {
391+ return Array.from(activeHandles);
392+}
393+
394+/**
395+ * Gets a loader handle by its ID.
396+ * @param {string} id - The handle ID
397+ * @returns {ActionLoaderHandle|undefined} The handle, or undefined if not found
398+ */
399+export function getLoaderHandleById(id) {
400+ for (const handle of activeHandles) {
401+ if (handle.id === id) {
402+ return handle;
403+ }
404+ }
405+ return undefined;
406+}
public/scripts/loader.js+4 -0
@@ -5,6 +5,10 @@ let loaderPopup;
55
66let preloaderYoinked = false;
77
8+export function isLoaderDisplayed() {
9+ return !!loaderPopup;
10+}
11+
812export function showLoader() {
913 // Two loaders don't make sense. Don't await, we can overlay the old loader while it closes
1014 if (loaderPopup) loaderPopup.complete(POPUP_RESULT.CANCELLED);
public/scripts/slash-commands.js+2 -0
@@ -77,6 +77,7 @@ import { SERVER_INPUTS, textgen_types, textgenerationwebui_settings } from './te
7777import { decodeTextTokens, getAvailableTokenizers, getFriendlyTokenizerName, getTextTokens, getTokenCountAsync, selectTokenizer } from './tokenizers.js';
7878import { debounce, delay, equalsIgnoreCaseAndAccents, findChar, getCharIndex, isFalseBoolean, isTrueBoolean, onlyUnique, regexFromString, showFontAwesomePicker, stringToRange, trimToEndSentence, trimToStartSentence, waitUntilCondition } from './utils.js';
7979import { registerVariableCommands, resolveVariable } from './variables.js';
80+import { registerActionLoaderSlashCommands } from './action-loader-slashcommands.js';
8081import { background_settings } from './backgrounds.js';
8182import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js';
8283import { SlashCommandClosureResult } from './slash-commands/SlashCommandClosureResult.js';
@@ -3714,6 +3715,7 @@ export function initDefaultSlashCommands() {
37143715 }));
37153716
37163717 registerVariableCommands();
3718+ registerActionLoaderSlashCommands();
37173719}
37183720
37193721const NARRATOR_NAME_KEY = 'narrator_name';
public/scripts/slash-commands/SlashCommandCommonEnumsProvider.js+2 -0
@@ -42,6 +42,8 @@ export const enumIcons = {
4242 image: '🖼️',
4343 video: '🎥',
4444 key: '🔑',
45+ spinner: '♻️',
46+ stop: '🛑',
4547
4648 true: '✔️',
4749 false: '❌',
public/scripts/st-context.js+2 -0
@@ -81,6 +81,7 @@ import {
8181import { groups, openGroupChat, selected_group, unshallowGroupMembers } from './group-chats.js';
8282import { addLocaleData, getCurrentLocale, t, translate } from './i18n.js';
8383import { hideLoader, showLoader } from './loader.js';
84+import { loader } from './action-loader.js';
8485import { MacrosParser } from './macros.js';
8586import { getChatCompletionModel, oai_settings } from './openai.js';
8687import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from './popup.js';
@@ -235,6 +236,7 @@ export function getContext() {
235236 scrollChatToBottom,
236237 scrollOnMediaLoad,
237238 macros,
239+ loader,
238240 swipe: {
239241 left: swipe_left,
240242 right: swipe_right,
public/style.css+5 -1
@@ -3973,7 +3973,7 @@ grammarly-extension {
39733973
39743974
39753975/* Override toastr default styles */
39763976body> #toast-container {
39773977 margin-top: var(--topBarBlockSize);
39783978}
39793979
@@ -3986,6 +3986,10 @@ body #toast-container>div {
39863986 width: 300px;
39873987}
39883988
3989+body #toast-container .toast.toast-non-interactable {
3990+ cursor: default;
3991+}
3992+
39893993body #toast-container .toast-success {
39903994 background-color: #5d9e5d;
39913995}